From 218f8e36a0f8b357f8f3c498b0704041480a3e07 Mon Sep 17 00:00:00 2001 From: "@tyeth" Date: Fri, 17 Jul 2026 11:18:12 +0100 Subject: [PATCH 1/8] fix(rebuild): include Hermes kanban board in the backup manifest kanban.db (a durable SQLite task board: tasks, comments, events, runs) and the kanban/ workspaces directory were not listed in the Hermes agent manifest state contract, so every sandbox rebuild silently dropped the board while restoring the rest of the agent state. Add kanban to state_dirs and kanban.db (sqlite_backup strategy, matching runtime/state.db) to state_files. Signed-off-by: tyeth --- agents/hermes/manifest.yaml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/agents/hermes/manifest.yaml b/agents/hermes/manifest.yaml index e786211e022..777f62c5184 100644 --- a/agents/hermes/manifest.yaml +++ b/agents/hermes/manifest.yaml @@ -84,6 +84,11 @@ state_dirs: # cursor survives a rebuild. The bot token itself comes from .env via # the L7 proxy and is not stored on disk inside the sandbox. - weixin + # Hermes' kanban board keeps per-task worker workspaces and run artifacts + # under ~/.hermes/kanban/. Scratch workspaces are ephemeral by design, but + # preserving the parent keeps worktree/dir workspaces and run logs across + # rebuilds, matching the board's durability contract. + - kanban # ── Top-level durable state files ─────────────────────────────── # NemoClaw stores Hermes gateway-created top-level state under runtime/ and @@ -99,6 +104,11 @@ state_files: - path: .hermes_history - path: runtime/state.db strategy: sqlite_backup + # Hermes' kanban board is a durable SQLite task board (tasks, comments, + # events, runs). Captured with the online backup API like runtime/state.db; + # the zero-byte kanban.db.*.lock files are intentionally omitted. + - path: kanban.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. From 4f182453a76655529e114ebfc0e09903ab82d18a Mon Sep 17 00:00:00 2001 From: "@tyeth" Date: Fri, 17 Jul 2026 11:47:07 +0100 Subject: [PATCH 2/8] test(rebuild): cover the kanban board in the Hermes durable-state snapshot Addresses PR Review Advisor blocker PRA-1: the Hermes durable-state snapshot fixture pinned the pre-change contract (SOUL.md, .hermes_history, runtime/state.db only). - Seed kanban.db and kanban/workspaces content in the fixture - Extend the fake SSH shim: dir existence check reports kanban, pre-backup audit walks the seeded state dir for unsafe entries, tar handlers stream the kanban dir both ways, and the SQLite backup/restore handlers key on the target path so kanban.db and runtime/state.db stay distinct - Update backup/restore expectations for the expanded manifest contract - Add a negative case: an unsafe symlink inside kanban/ makes the pre-backup security audit reject the backup (NC-2227-04 coverage on the new writable state surface) Both tests pass locally: npx vitest run test/snapshot.test.ts -t Hermes / -t 'unsafe symlink'. Signed-off-by: tyeth --- test/snapshot.test.ts | 178 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 173 insertions(+), 5 deletions(-) diff --git a/test/snapshot.test.ts b/test/snapshot.test.ts index 12fcec71749..4ecefcffd0f 100644 --- a/test/snapshot.test.ts +++ b/test/snapshot.test.ts @@ -1115,6 +1115,88 @@ process.exit(0); fs.rmSync(fixture, { recursive: true, force: true }); } }); + + it("rejects backup when the kanban state dir contains an unsafe symlink", () => { + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-kanban-unsafe-")); + const oldPath = process.env.PATH; + const oldOpenshell = process.env.NEMOCLAW_OPENSHELL_BIN; + try { + const binDir = path.join(fixture, "bin"); + const fakeRoot = path.join(fixture, "sandbox-root"); + const hermesDir = path.join(fakeRoot, ".hermes"); + const kanbanDir = path.join(hermesDir, "kanban"); + fs.mkdirSync(binDir, { recursive: true }); + fs.mkdirSync(kanbanDir, { recursive: true }); + fs.writeFileSync(path.join(hermesDir, "SOUL.md"), "soul\n"); + // NC-2227-04 regression cover on the new writable state surface: a + // compromised agent could plant kanban/evil -> config to exfiltrate + // via backup, so the pre-backup audit must reject it. + fs.symlinkSync("/etc/passwd", path.join(kanbanDir, "evil")); + + const openshell = path.join(binDir, "openshell"); + writeExecutable( + openshell, + `#!/usr/bin/env node +const args = process.argv.slice(2); +if (args[0] === "sandbox" && args[1] === "ssh-config") { + process.stdout.write("Host openshell-hermes\\n HostName 127.0.0.1\\n User sandbox\\n"); + process.exit(0); +} +process.exit(0); +`, + ); + + writeExecutable( + path.join(binDir, "ssh"), + `#!/usr/bin/env node +const cmd = process.argv[process.argv.length - 1] || ""; +if (cmd.includes("[ -d ")) { + process.stdout.write("kanban\\n"); + process.exit(0); +} +if (cmd.includes("-printf")) { + process.stdout.write("l\\t/sandbox/.hermes/kanban/evil\\t/etc/passwd\\n"); + process.exit(0); +} +process.exit(0); +`, + ); + + fs.mkdirSync(path.join(TMP_HOME, ".nemoclaw"), { recursive: true }); + fs.writeFileSync( + path.join(TMP_HOME, ".nemoclaw", "sandboxes.json"), + JSON.stringify({ + defaultSandbox: "hermes", + sandboxes: { + hermes: { + name: "hermes", + model: "m", + provider: "p", + gpuEnabled: false, + policies: [], + agent: "hermes", + }, + }, + }), + ); + + process.env.NEMOCLAW_OPENSHELL_BIN = openshell; + process.env.PATH = `${binDir}:${oldPath || ""}`; + + const backup = sandboxState.backupSandboxState("hermes", { name: "hermes-unsafe" }); + expect(backup.success).toBe(false); + expect(backup.error).toContain("Pre-backup audit rejected"); + expect(backup.failedDirs).toContain("kanban"); + } finally { + if (oldOpenshell === undefined) { + delete process.env.NEMOCLAW_OPENSHELL_BIN; + } else { + process.env.NEMOCLAW_OPENSHELL_BIN = oldOpenshell; + } + process.env.PATH = oldPath; + fs.rmSync(fixture, { recursive: true, force: true }); + } + }); }); describe("Deep Agents Code durable state files", () => { @@ -1285,6 +1367,12 @@ describe("Hermes durable state files", () => { fs.writeFileSync(path.join(hermesDir, "SOUL.md"), "original soul\n"); fs.writeFileSync(path.join(hermesDir, ".hermes_history"), "original history\n"); fs.writeFileSync(path.join(runtimeDir, "state.db"), "original sqlite backup\n"); + fs.writeFileSync(path.join(hermesDir, "kanban.db"), "original kanban backup\n"); + fs.mkdirSync(path.join(hermesDir, "kanban", "workspaces", "t_1"), { recursive: true }); + fs.writeFileSync( + path.join(hermesDir, "kanban", "workspaces", "t_1", "run.log"), + "original run log\n", + ); fs.writeFileSync(path.join(hermesDir, "config.yaml"), "token: should-not-copy\n"); fs.writeFileSync(path.join(hermesDir, ".env"), "API_TOKEN=should-not-copy\n"); fs.writeFileSync(path.join(hermesDir, "auth.json"), '{"token":"should-not-copy"}\n'); @@ -1323,10 +1411,53 @@ function readStdin() { return Buffer.concat(chunks); } if (cmd.includes("[ -d ")) { + if (fs.existsSync(path.join(hermesDir, "kanban"))) { + process.stdout.write("kanban\\n"); + } + process.exit(0); +} +if (cmd.includes("-printf")) { + // Mirror the pre-backup audit: report symlinks, hard links, and special + // files under the declared state dirs (only kanban is seeded here). + const rows = []; + const walk = (abs, remote) => { + for (const entry of fs.readdirSync(abs)) { + const absEntry = path.join(abs, entry); + const remoteEntry = remote + "/" + entry; + const st = fs.lstatSync(absEntry); + if (st.isSymbolicLink()) { + rows.push("l\\t" + remoteEntry + "\\t" + fs.readlinkSync(absEntry)); + } else if (st.isDirectory()) { + walk(absEntry, remoteEntry); + } else if (!st.isFile()) { + rows.push("?\\t" + remoteEntry + "\\t"); + } + } + }; + const kanbanDir = path.join(hermesDir, "kanban"); + if (fs.existsSync(kanbanDir)) walk(kanbanDir, "/sandbox/.hermes/kanban"); + if (rows.length > 0) process.stdout.write(rows.join("\\n") + "\\n"); + process.exit(0); +} +if (cmd.startsWith("tar -cf -")) { + const { execFileSync } = require("child_process"); + process.stdout.write( + execFileSync("tar", ["-cf", "-", "-C", hermesDir, "--", "kanban"], { + maxBuffer: 64 * 1024 * 1024, + }), + ); + process.exit(0); +} +if (cmd.includes("tar --no-same-owner -xf -")) { + const { execFileSync } = require("child_process"); + execFileSync("tar", ["--no-same-owner", "-xf", "-", "-C", hermesDir], { input: readStdin() }); process.exit(0); } if (cmd.includes("nemoclaw-sqlite-backup")) { - process.stdout.write(fs.readFileSync(path.join(hermesDir, "runtime", "state.db"))); + const src = cmd.includes("kanban.db") + ? path.join(hermesDir, "kanban.db") + : path.join(hermesDir, "runtime", "state.db"); + process.stdout.write(fs.readFileSync(src)); process.exit(0); } if (cmd.includes("SOUL.md") && cmd.includes("cat --")) { @@ -1338,8 +1469,12 @@ if (cmd.includes(".hermes_history") && cmd.includes("cat --")) { process.exit(0); } if (cmd.includes("nemoclaw-sqlite-restore")) { - fs.mkdirSync(path.join(hermesDir, "runtime"), { recursive: true }); - fs.writeFileSync(path.join(hermesDir, "runtime", "state.db"), readStdin()); + if (cmd.includes("kanban.db")) { + fs.writeFileSync(path.join(hermesDir, "kanban.db"), readStdin()); + } else { + fs.mkdirSync(path.join(hermesDir, "runtime"), { recursive: true }); + fs.writeFileSync(path.join(hermesDir, "runtime", "state.db"), readStdin()); + } process.exit(0); } if (cmd.includes(".nemoclaw-restore") && cmd.includes("SOUL.md")) { @@ -1377,12 +1512,19 @@ process.exit(0); const backup = sandboxState.backupSandboxState("hermes", { name: "hermes-state" }); expect(backup.success).toBe(true); - expect(backup.backedUpFiles).toEqual(["SOUL.md", ".hermes_history", "runtime/state.db"]); + expect(backup.backedUpFiles).toEqual([ + "SOUL.md", + ".hermes_history", + "runtime/state.db", + "kanban.db", + ]); expect(backup.failedFiles).toEqual([]); + expect(backup.backedUpDirs).toContain("kanban"); expect(backup.manifest?.stateFiles).toEqual([ { path: "SOUL.md", strategy: "copy" }, { path: ".hermes_history", strategy: "copy" }, { path: "runtime/state.db", strategy: "sqlite_backup" }, + { path: "kanban.db", strategy: "sqlite_backup" }, ]); expect(fs.readFileSync(path.join(backup.manifest!.backupPath, "SOUL.md"), "utf-8")).toBe( "original soul\n", @@ -1393,6 +1535,15 @@ process.exit(0); expect( fs.readFileSync(path.join(backup.manifest!.backupPath, "runtime", "state.db"), "utf-8"), ).toBe("original sqlite backup\n"); + expect( + fs.readFileSync(path.join(backup.manifest!.backupPath, "kanban.db"), "utf-8"), + ).toBe("original kanban backup\n"); + expect( + fs.readFileSync( + path.join(backup.manifest!.backupPath, "kanban", "workspaces", "t_1", "run.log"), + "utf-8", + ), + ).toBe("original run log\n"); expect(fs.existsSync(path.join(backup.manifest!.backupPath, "config.yaml"))).toBe(false); expect(fs.existsSync(path.join(backup.manifest!.backupPath, ".env"))).toBe(false); expect(fs.existsSync(path.join(backup.manifest!.backupPath, "auth.json"))).toBe(false); @@ -1400,9 +1551,20 @@ process.exit(0); fs.writeFileSync(path.join(hermesDir, "SOUL.md"), "changed soul\n"); fs.writeFileSync(path.join(hermesDir, ".hermes_history"), "changed history\n"); fs.writeFileSync(path.join(runtimeDir, "state.db"), "changed db\n"); + fs.writeFileSync(path.join(hermesDir, "kanban.db"), "changed kanban db\n"); + fs.writeFileSync( + path.join(hermesDir, "kanban", "workspaces", "t_1", "run.log"), + "changed run log\n", + ); const restore = sandboxState.restoreSandboxState("hermes", backup.manifest!.backupPath); expect(restore.success).toBe(true); - expect(restore.restoredFiles).toEqual(["SOUL.md", ".hermes_history", "runtime/state.db"]); + expect(restore.restoredFiles).toEqual([ + "SOUL.md", + ".hermes_history", + "runtime/state.db", + "kanban.db", + ]); + expect(restore.restoredDirs).toContain("kanban"); expect(fs.readFileSync(path.join(hermesDir, "SOUL.md"), "utf-8")).toBe("original soul\n"); expect(fs.readFileSync(path.join(hermesDir, ".hermes_history"), "utf-8")).toBe( "original history\n", @@ -1410,6 +1572,12 @@ process.exit(0); expect(fs.readFileSync(path.join(runtimeDir, "state.db"), "utf-8")).toBe( "original sqlite backup\n", ); + expect(fs.readFileSync(path.join(hermesDir, "kanban.db"), "utf-8")).toBe( + "original kanban backup\n", + ); + expect( + fs.readFileSync(path.join(hermesDir, "kanban", "workspaces", "t_1", "run.log"), "utf-8"), + ).toBe("original run log\n"); const loggedCommands = fs.readFileSync(sshLog, "utf-8"); expect(loggedCommands).toContain("sqlite3.connect"); From 0bad399d062e98cdcd65c56f885577e83fe7c7f7 Mon Sep 17 00:00:00 2001 From: "@tyeth" Date: Fri, 17 Jul 2026 11:57:15 +0100 Subject: [PATCH 3/8] test(rebuild): report hard-linked files in the fake pre-backup audit The fake audit walker in the Hermes snapshot fixture claimed to mirror the production pre-backup audit but never reported regular files with st.nlink > 1, so fixture-based hard-link coverage would pass where production rejects. Emit an 'f' row for hard-linked regular files, matching find's -type f -a -links +1 branch. Both Hermes snapshot tests re-verified locally with vitest. Signed-off-by: tyeth --- test/snapshot.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/snapshot.test.ts b/test/snapshot.test.ts index 4ecefcffd0f..7c06c2fab7d 100644 --- a/test/snapshot.test.ts +++ b/test/snapshot.test.ts @@ -1429,6 +1429,8 @@ if (cmd.includes("-printf")) { rows.push("l\\t" + remoteEntry + "\\t" + fs.readlinkSync(absEntry)); } else if (st.isDirectory()) { walk(absEntry, remoteEntry); + } else if (st.isFile() && st.nlink > 1) { + rows.push("f\\t" + remoteEntry + "\\t"); } else if (!st.isFile()) { rows.push("?\\t" + remoteEntry + "\\t"); } From fa9c751fe928fd18579b9eee946cb783abd8636a Mon Sep 17 00:00:00 2001 From: "@tyeth" Date: Fri, 17 Jul 2026 12:56:03 +0100 Subject: [PATCH 4/8] chore(ci): ratchet snapshot.test.ts into the test-file size budget The kanban coverage added for PRA-1 grew test/snapshot.test.ts past the 1500-line default ceiling (now 1598 lines). Add the ratchet entry at the file's exact current length, per the check's lower-the-budget contract (scripts/check-test-file-size-budget.mts rejects any headroom). Verified locally: npx tsx scripts/check-test-file-size-budget.mts passes (1627 files scanned). Signed-off-by: tyeth --- ci/test-file-size-budget.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index 0fd77977b5e..4fada55a275 100644 --- a/ci/test-file-size-budget.json +++ b/ci/test-file-size-budget.json @@ -10,6 +10,7 @@ "test/nemoclaw-start.test.ts": 4826, "test/onboard-messaging.test.ts": 2049, "test/onboard-selection.test.ts": 4769, - "test/policies.test.ts": 1531 + "test/policies.test.ts": 1531, + "test/snapshot.test.ts": 1598 } } From 1f1a7b29db956f2661976ed74142ea66d68b2f11 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Fri, 17 Jul 2026 15:36:16 -0700 Subject: [PATCH 5/8] fix(rebuild): narrow Hermes kanban backup scope Preserve only the backward-compatible default-board SQLite database and explicitly exclude state that cannot be safely or completely archived by the current manifest contract. Co-authored-by: tyeth Signed-off-by: Apurv Kumaria --- agents/hermes/manifest.yaml | 14 +-- ci/test-file-size-budget.json | 3 +- test/hermes-kanban-snapshot.test.ts | 183 ++++++++++++++++++++++++++++ test/snapshot.test.ts | 180 +-------------------------- 4 files changed, 196 insertions(+), 184 deletions(-) create mode 100644 test/hermes-kanban-snapshot.test.ts diff --git a/agents/hermes/manifest.yaml b/agents/hermes/manifest.yaml index 777f62c5184..4003b773c76 100644 --- a/agents/hermes/manifest.yaml +++ b/agents/hermes/manifest.yaml @@ -84,11 +84,6 @@ state_dirs: # cursor survives a rebuild. The bot token itself comes from .env via # the L7 proxy and is not stored on disk inside the sandbox. - weixin - # Hermes' kanban board keeps per-task worker workspaces and run artifacts - # under ~/.hermes/kanban/. Scratch workspaces are ephemeral by design, but - # preserving the parent keeps worktree/dir workspaces and run logs across - # rebuilds, matching the board's durability contract. - - kanban # ── Top-level durable state files ─────────────────────────────── # NemoClaw stores Hermes gateway-created top-level state under runtime/ and @@ -104,9 +99,12 @@ state_files: - path: .hermes_history - path: runtime/state.db strategy: sqlite_backup - # Hermes' kanban board is a durable SQLite task board (tasks, comments, - # events, runs). Captured with the online backup API like runtime/state.db; - # the zero-byte kanban.db.*.lock files are intentionally omitted. + # Hermes' backward-compatible default board lives in this SQLite database. + # Capture it with the online backup API like runtime/state.db; the zero-byte + # kanban.db.*.lock files are intentionally omitted. This does not cover named + # boards, attachments, worker logs, or scratch workspaces under kanban/, nor + # external dir/worktree workspace targets. Those need a separate durability + # design before the sibling kanban/ tree can safely enter the state contract. - path: kanban.db strategy: sqlite_backup user_managed_files: diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index 4fada55a275..0fd77977b5e 100644 --- a/ci/test-file-size-budget.json +++ b/ci/test-file-size-budget.json @@ -10,7 +10,6 @@ "test/nemoclaw-start.test.ts": 4826, "test/onboard-messaging.test.ts": 2049, "test/onboard-selection.test.ts": 4769, - "test/policies.test.ts": 1531, - "test/snapshot.test.ts": 1598 + "test/policies.test.ts": 1531 } } diff --git a/test/hermes-kanban-snapshot.test.ts b/test/hermes-kanban-snapshot.test.ts new file mode 100644 index 00000000000..7843ca8bd0b --- /dev/null +++ b/test/hermes-kanban-snapshot.test.ts @@ -0,0 +1,183 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterAll, expect, it } from "vitest"; + +// sandbox-state captures HOME when the module loads, so isolate its registry +// and rebuild backups before importing it. +const ORIGINAL_HOME = process.env.HOME; +const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-kanban-snapshot-")); +process.env.HOME = TMP_HOME; +const sandboxState = await import( + pathToFileURL(path.join(import.meta.dirname, "..", "src", "lib", "state", "sandbox.ts")).href +); + +afterAll(() => { + ORIGINAL_HOME === undefined ? delete process.env.HOME : (process.env.HOME = ORIGINAL_HOME); + fs.rmSync(TMP_HOME, { recursive: true, force: true }); +}); + +function writeExecutable(filePath: string, source: string): void { + fs.writeFileSync(filePath, source, { mode: 0o755 }); +} + +function writeHermesRegistry(): void { + fs.mkdirSync(path.join(TMP_HOME, ".nemoclaw"), { recursive: true }); + fs.writeFileSync( + path.join(TMP_HOME, ".nemoclaw", "sandboxes.json"), + JSON.stringify({ + defaultSandbox: "hermes", + sandboxes: { + hermes: { + name: "hermes", + model: "m", + provider: "p", + gpuEnabled: false, + policies: [], + agent: "hermes", + }, + }, + }), + ); +} + +it("preserves only the Hermes default-board database across rebuilds (#7095)", () => { + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-kanban-state-")); + const oldPath = process.env.PATH; + const oldOpenshell = process.env.NEMOCLAW_OPENSHELL_BIN; + try { + const binDir = path.join(fixture, "bin"); + const hermesDir = path.join(fixture, "sandbox-root", ".hermes"); + const scratchFile = path.join(hermesDir, "kanban", "workspaces", "scratch", "work.txt"); + const namedBoardDb = path.join(hermesDir, "kanban", "boards", "release-board", "kanban.db"); + const attachmentFile = path.join(hermesDir, "kanban", "attachments", "t_1", "design.txt"); + const workerLog = path.join(hermesDir, "kanban", "logs", "t_1.log"); + const externalDirFile = path.join(fixture, "external-dir-workspace", "work.txt"); + const externalWorktreeFile = path.join(fixture, "external-worktree", "work.txt"); + const sshLog = path.join(fixture, "ssh-log.jsonl"); + fs.mkdirSync(binDir, { recursive: true }); + fs.mkdirSync(path.dirname(scratchFile), { recursive: true }); + fs.mkdirSync(path.dirname(namedBoardDb), { recursive: true }); + fs.mkdirSync(path.dirname(attachmentFile), { recursive: true }); + fs.mkdirSync(path.dirname(workerLog), { recursive: true }); + fs.mkdirSync(path.dirname(externalDirFile), { recursive: true }); + fs.mkdirSync(path.dirname(externalWorktreeFile), { recursive: true }); + fs.writeFileSync(path.join(hermesDir, "kanban.db"), "original kanban database\n"); + fs.writeFileSync(scratchFile, "old scratch workspace\n"); + fs.writeFileSync(namedBoardDb, "old named-board database\n"); + fs.writeFileSync(attachmentFile, "old attachment\n"); + fs.writeFileSync(workerLog, "old worker log\n"); + fs.writeFileSync(externalDirFile, "old external dir workspace\n"); + fs.writeFileSync(externalWorktreeFile, "old external worktree\n"); + + const openshell = path.join(binDir, "openshell"); + writeExecutable( + openshell, + `#!/usr/bin/env node +const args = process.argv.slice(2); +if (args[0] === "sandbox" && args[1] === "ssh-config") { + process.stdout.write("Host openshell-hermes\\n HostName 127.0.0.1\\n User sandbox\\n"); + process.exit(0); +} +process.exit(0); +`, + ); + + writeExecutable( + path.join(binDir, "ssh"), + `#!/usr/bin/env node +const fs = require("node:fs"); +const path = require("node:path"); +const cmd = process.argv[process.argv.length - 1] || ""; +const hermesDir = ${JSON.stringify(hermesDir)}; +fs.appendFileSync(${JSON.stringify(sshLog)}, JSON.stringify({ cmd }) + "\\n"); +function readStdin() { + const chunks = []; + for (;;) { + const buffer = Buffer.alloc(65536); + const count = fs.readSync(0, buffer, 0, buffer.length, null); + if (count === 0) break; + chunks.push(buffer.subarray(0, count)); + } + return Buffer.concat(chunks); +} +if (cmd.includes("[ -d ")) { + process.exit(0); +} +if (cmd.includes("nemoclaw-sqlite-backup")) { + if (!cmd.includes("kanban.db")) process.exit(2); + process.stdout.write(fs.readFileSync(path.join(hermesDir, "kanban.db"))); + process.exit(0); +} +if (cmd.includes("SOUL.md") || cmd.includes(".hermes_history")) { + process.exit(2); +} +if (cmd.includes("nemoclaw-sqlite-restore")) { + fs.writeFileSync(path.join(hermesDir, "kanban.db"), readStdin()); + process.exit(0); +} +process.exit(0); +`, + ); + + writeHermesRegistry(); + process.env.NEMOCLAW_OPENSHELL_BIN = openshell; + process.env.PATH = `${binDir}${path.delimiter}${oldPath || ""}`; + + const backup = sandboxState.backupSandboxState("hermes", { name: "kanban-state" }); + expect(backup.success).toBe(true); + expect(backup.backedUpFiles).toEqual(["kanban.db"]); + expect(backup.failedFiles).toEqual([]); + expect(backup.backedUpDirs).not.toContain("kanban"); + expect(backup.manifest?.stateDirs).not.toContain("kanban"); + expect(backup.manifest?.stateFiles).toContainEqual({ + path: "kanban.db", + strategy: "sqlite_backup", + }); + expect(fs.readFileSync(path.join(backup.manifest!.backupPath, "kanban.db"), "utf-8")).toBe( + "original kanban database\n", + ); + expect(fs.existsSync(path.join(backup.manifest!.backupPath, "kanban"))).toBe(false); + + fs.writeFileSync(path.join(hermesDir, "kanban.db"), "changed kanban database\n"); + fs.writeFileSync(scratchFile, "fresh scratch workspace\n"); + fs.writeFileSync(namedBoardDb, "fresh named-board database\n"); + fs.writeFileSync(attachmentFile, "fresh attachment\n"); + fs.writeFileSync(workerLog, "fresh worker log\n"); + fs.writeFileSync(externalDirFile, "fresh external dir workspace\n"); + fs.writeFileSync(externalWorktreeFile, "fresh external worktree\n"); + + const restore = sandboxState.restoreSandboxState("hermes", backup.manifest!.backupPath); + expect(restore.success).toBe(true); + expect(restore.restoredFiles).toEqual(["kanban.db"]); + expect(restore.restoredDirs).toEqual([]); + expect(fs.readFileSync(path.join(hermesDir, "kanban.db"), "utf-8")).toBe( + "original kanban database\n", + ); + expect(fs.readFileSync(scratchFile, "utf-8")).toBe("fresh scratch workspace\n"); + expect(fs.readFileSync(namedBoardDb, "utf-8")).toBe("fresh named-board database\n"); + expect(fs.readFileSync(attachmentFile, "utf-8")).toBe("fresh attachment\n"); + expect(fs.readFileSync(workerLog, "utf-8")).toBe("fresh worker log\n"); + expect(fs.readFileSync(externalDirFile, "utf-8")).toBe("fresh external dir workspace\n"); + expect(fs.readFileSync(externalWorktreeFile, "utf-8")).toBe("fresh external worktree\n"); + + const loggedCommands = fs.readFileSync(sshLog, "utf-8"); + expect(loggedCommands).toContain("sqlite3.connect"); + expect(loggedCommands).not.toContain("tar -cf -"); + expect(loggedCommands).not.toContain("kanban/boards/release-board"); + expect(loggedCommands).not.toContain("kanban/attachments"); + expect(loggedCommands).not.toContain("kanban/logs"); + expect(loggedCommands).not.toContain(externalDirFile); + expect(loggedCommands).not.toContain(externalWorktreeFile); + } finally { + oldOpenshell === undefined + ? delete process.env.NEMOCLAW_OPENSHELL_BIN + : (process.env.NEMOCLAW_OPENSHELL_BIN = oldOpenshell); + oldPath === undefined ? delete process.env.PATH : (process.env.PATH = oldPath); + fs.rmSync(fixture, { recursive: true, force: true }); + } +}); diff --git a/test/snapshot.test.ts b/test/snapshot.test.ts index 7c06c2fab7d..7d77f3ad0a6 100644 --- a/test/snapshot.test.ts +++ b/test/snapshot.test.ts @@ -1115,88 +1115,6 @@ process.exit(0); fs.rmSync(fixture, { recursive: true, force: true }); } }); - - it("rejects backup when the kanban state dir contains an unsafe symlink", () => { - const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-kanban-unsafe-")); - const oldPath = process.env.PATH; - const oldOpenshell = process.env.NEMOCLAW_OPENSHELL_BIN; - try { - const binDir = path.join(fixture, "bin"); - const fakeRoot = path.join(fixture, "sandbox-root"); - const hermesDir = path.join(fakeRoot, ".hermes"); - const kanbanDir = path.join(hermesDir, "kanban"); - fs.mkdirSync(binDir, { recursive: true }); - fs.mkdirSync(kanbanDir, { recursive: true }); - fs.writeFileSync(path.join(hermesDir, "SOUL.md"), "soul\n"); - // NC-2227-04 regression cover on the new writable state surface: a - // compromised agent could plant kanban/evil -> config to exfiltrate - // via backup, so the pre-backup audit must reject it. - fs.symlinkSync("/etc/passwd", path.join(kanbanDir, "evil")); - - const openshell = path.join(binDir, "openshell"); - writeExecutable( - openshell, - `#!/usr/bin/env node -const args = process.argv.slice(2); -if (args[0] === "sandbox" && args[1] === "ssh-config") { - process.stdout.write("Host openshell-hermes\\n HostName 127.0.0.1\\n User sandbox\\n"); - process.exit(0); -} -process.exit(0); -`, - ); - - writeExecutable( - path.join(binDir, "ssh"), - `#!/usr/bin/env node -const cmd = process.argv[process.argv.length - 1] || ""; -if (cmd.includes("[ -d ")) { - process.stdout.write("kanban\\n"); - process.exit(0); -} -if (cmd.includes("-printf")) { - process.stdout.write("l\\t/sandbox/.hermes/kanban/evil\\t/etc/passwd\\n"); - process.exit(0); -} -process.exit(0); -`, - ); - - fs.mkdirSync(path.join(TMP_HOME, ".nemoclaw"), { recursive: true }); - fs.writeFileSync( - path.join(TMP_HOME, ".nemoclaw", "sandboxes.json"), - JSON.stringify({ - defaultSandbox: "hermes", - sandboxes: { - hermes: { - name: "hermes", - model: "m", - provider: "p", - gpuEnabled: false, - policies: [], - agent: "hermes", - }, - }, - }), - ); - - process.env.NEMOCLAW_OPENSHELL_BIN = openshell; - process.env.PATH = `${binDir}:${oldPath || ""}`; - - const backup = sandboxState.backupSandboxState("hermes", { name: "hermes-unsafe" }); - expect(backup.success).toBe(false); - expect(backup.error).toContain("Pre-backup audit rejected"); - expect(backup.failedDirs).toContain("kanban"); - } finally { - if (oldOpenshell === undefined) { - delete process.env.NEMOCLAW_OPENSHELL_BIN; - } else { - process.env.NEMOCLAW_OPENSHELL_BIN = oldOpenshell; - } - process.env.PATH = oldPath; - fs.rmSync(fixture, { recursive: true, force: true }); - } - }); }); describe("Deep Agents Code durable state files", () => { @@ -1367,12 +1285,6 @@ describe("Hermes durable state files", () => { fs.writeFileSync(path.join(hermesDir, "SOUL.md"), "original soul\n"); fs.writeFileSync(path.join(hermesDir, ".hermes_history"), "original history\n"); fs.writeFileSync(path.join(runtimeDir, "state.db"), "original sqlite backup\n"); - fs.writeFileSync(path.join(hermesDir, "kanban.db"), "original kanban backup\n"); - fs.mkdirSync(path.join(hermesDir, "kanban", "workspaces", "t_1"), { recursive: true }); - fs.writeFileSync( - path.join(hermesDir, "kanban", "workspaces", "t_1", "run.log"), - "original run log\n", - ); fs.writeFileSync(path.join(hermesDir, "config.yaml"), "token: should-not-copy\n"); fs.writeFileSync(path.join(hermesDir, ".env"), "API_TOKEN=should-not-copy\n"); fs.writeFileSync(path.join(hermesDir, "auth.json"), '{"token":"should-not-copy"}\n'); @@ -1411,55 +1323,11 @@ function readStdin() { return Buffer.concat(chunks); } if (cmd.includes("[ -d ")) { - if (fs.existsSync(path.join(hermesDir, "kanban"))) { - process.stdout.write("kanban\\n"); - } - process.exit(0); -} -if (cmd.includes("-printf")) { - // Mirror the pre-backup audit: report symlinks, hard links, and special - // files under the declared state dirs (only kanban is seeded here). - const rows = []; - const walk = (abs, remote) => { - for (const entry of fs.readdirSync(abs)) { - const absEntry = path.join(abs, entry); - const remoteEntry = remote + "/" + entry; - const st = fs.lstatSync(absEntry); - if (st.isSymbolicLink()) { - rows.push("l\\t" + remoteEntry + "\\t" + fs.readlinkSync(absEntry)); - } else if (st.isDirectory()) { - walk(absEntry, remoteEntry); - } else if (st.isFile() && st.nlink > 1) { - rows.push("f\\t" + remoteEntry + "\\t"); - } else if (!st.isFile()) { - rows.push("?\\t" + remoteEntry + "\\t"); - } - } - }; - const kanbanDir = path.join(hermesDir, "kanban"); - if (fs.existsSync(kanbanDir)) walk(kanbanDir, "/sandbox/.hermes/kanban"); - if (rows.length > 0) process.stdout.write(rows.join("\\n") + "\\n"); - process.exit(0); -} -if (cmd.startsWith("tar -cf -")) { - const { execFileSync } = require("child_process"); - process.stdout.write( - execFileSync("tar", ["-cf", "-", "-C", hermesDir, "--", "kanban"], { - maxBuffer: 64 * 1024 * 1024, - }), - ); - process.exit(0); -} -if (cmd.includes("tar --no-same-owner -xf -")) { - const { execFileSync } = require("child_process"); - execFileSync("tar", ["--no-same-owner", "-xf", "-", "-C", hermesDir], { input: readStdin() }); process.exit(0); } if (cmd.includes("nemoclaw-sqlite-backup")) { - const src = cmd.includes("kanban.db") - ? path.join(hermesDir, "kanban.db") - : path.join(hermesDir, "runtime", "state.db"); - process.stdout.write(fs.readFileSync(src)); + if (cmd.includes("kanban.db")) process.exit(2); + process.stdout.write(fs.readFileSync(path.join(hermesDir, "runtime", "state.db"))); process.exit(0); } if (cmd.includes("SOUL.md") && cmd.includes("cat --")) { @@ -1471,12 +1339,8 @@ if (cmd.includes(".hermes_history") && cmd.includes("cat --")) { process.exit(0); } if (cmd.includes("nemoclaw-sqlite-restore")) { - if (cmd.includes("kanban.db")) { - fs.writeFileSync(path.join(hermesDir, "kanban.db"), readStdin()); - } else { - fs.mkdirSync(path.join(hermesDir, "runtime"), { recursive: true }); - fs.writeFileSync(path.join(hermesDir, "runtime", "state.db"), readStdin()); - } + fs.mkdirSync(path.join(hermesDir, "runtime"), { recursive: true }); + fs.writeFileSync(path.join(hermesDir, "runtime", "state.db"), readStdin()); process.exit(0); } if (cmd.includes(".nemoclaw-restore") && cmd.includes("SOUL.md")) { @@ -1514,14 +1378,8 @@ process.exit(0); const backup = sandboxState.backupSandboxState("hermes", { name: "hermes-state" }); expect(backup.success).toBe(true); - expect(backup.backedUpFiles).toEqual([ - "SOUL.md", - ".hermes_history", - "runtime/state.db", - "kanban.db", - ]); + expect(backup.backedUpFiles).toEqual(["SOUL.md", ".hermes_history", "runtime/state.db"]); expect(backup.failedFiles).toEqual([]); - expect(backup.backedUpDirs).toContain("kanban"); expect(backup.manifest?.stateFiles).toEqual([ { path: "SOUL.md", strategy: "copy" }, { path: ".hermes_history", strategy: "copy" }, @@ -1537,15 +1395,6 @@ process.exit(0); expect( fs.readFileSync(path.join(backup.manifest!.backupPath, "runtime", "state.db"), "utf-8"), ).toBe("original sqlite backup\n"); - expect( - fs.readFileSync(path.join(backup.manifest!.backupPath, "kanban.db"), "utf-8"), - ).toBe("original kanban backup\n"); - expect( - fs.readFileSync( - path.join(backup.manifest!.backupPath, "kanban", "workspaces", "t_1", "run.log"), - "utf-8", - ), - ).toBe("original run log\n"); expect(fs.existsSync(path.join(backup.manifest!.backupPath, "config.yaml"))).toBe(false); expect(fs.existsSync(path.join(backup.manifest!.backupPath, ".env"))).toBe(false); expect(fs.existsSync(path.join(backup.manifest!.backupPath, "auth.json"))).toBe(false); @@ -1553,20 +1402,9 @@ process.exit(0); fs.writeFileSync(path.join(hermesDir, "SOUL.md"), "changed soul\n"); fs.writeFileSync(path.join(hermesDir, ".hermes_history"), "changed history\n"); fs.writeFileSync(path.join(runtimeDir, "state.db"), "changed db\n"); - fs.writeFileSync(path.join(hermesDir, "kanban.db"), "changed kanban db\n"); - fs.writeFileSync( - path.join(hermesDir, "kanban", "workspaces", "t_1", "run.log"), - "changed run log\n", - ); const restore = sandboxState.restoreSandboxState("hermes", backup.manifest!.backupPath); expect(restore.success).toBe(true); - expect(restore.restoredFiles).toEqual([ - "SOUL.md", - ".hermes_history", - "runtime/state.db", - "kanban.db", - ]); - expect(restore.restoredDirs).toContain("kanban"); + expect(restore.restoredFiles).toEqual(["SOUL.md", ".hermes_history", "runtime/state.db"]); expect(fs.readFileSync(path.join(hermesDir, "SOUL.md"), "utf-8")).toBe("original soul\n"); expect(fs.readFileSync(path.join(hermesDir, ".hermes_history"), "utf-8")).toBe( "original history\n", @@ -1574,12 +1412,6 @@ process.exit(0); expect(fs.readFileSync(path.join(runtimeDir, "state.db"), "utf-8")).toBe( "original sqlite backup\n", ); - expect(fs.readFileSync(path.join(hermesDir, "kanban.db"), "utf-8")).toBe( - "original kanban backup\n", - ); - expect( - fs.readFileSync(path.join(hermesDir, "kanban", "workspaces", "t_1", "run.log"), "utf-8"), - ).toBe("original run log\n"); const loggedCommands = fs.readFileSync(sshLog, "utf-8"); expect(loggedCommands).toContain("sqlite3.connect"); From 89477fd18a1e1e12b81b3575813bea1eb46e11c1 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 18 Jul 2026 02:21:49 -0700 Subject: [PATCH 6/8] test(snapshot): keep split fixture within size budget Signed-off-by: Carlos Villela --- test/snapshot.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/snapshot.test.ts b/test/snapshot.test.ts index 2c71d16d0d9..c43ed4fe0ef 100644 --- a/test/snapshot.test.ts +++ b/test/snapshot.test.ts @@ -1323,9 +1323,7 @@ process.exit(0); expect(loggedCommands).not.toContain(".env"); expect(loggedCommands).not.toContain(".mcp.json"); expect(loggedCommands).not.toContain(".nemoclaw-mcp.json"); - - // #5753 is "lost after rebuild" (backup + recreate + restore): restore - // must list agent/skills among the dirs it brings back into the sandbox. + // #5753: restore must include agent/skills after backup and recreation. const restore = sandboxState.restoreSandboxState("deepagents", backup.manifest!.backupPath); expect(restore.success).toBe(true); expect(restore.restoredDirs).toEqual( From 46dea8a03676c2f3aa9c9253df363c8e27b02999 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 18 Jul 2026 09:27:49 -0700 Subject: [PATCH 7/8] docs(hermes): document kanban backup scope --- docs/manage-sandboxes/backup-restore.mdx | 9 ++++++--- docs/manage-sandboxes/workspace-files.mdx | 4 +++- docs/reference/commands.mdx | 3 ++- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/manage-sandboxes/backup-restore.mdx b/docs/manage-sandboxes/backup-restore.mdx index e810bd3db6c..3f97cd96baf 100644 --- a/docs/manage-sandboxes/backup-restore.mdx +++ b/docs/manage-sandboxes/backup-restore.mdx @@ -39,11 +39,14 @@ Agent manifests can also declare durable top-level state files. Treat snapshot directories as private local data. -Hermes snapshots include `SOUL.md`, the Web Dashboard profile under `.hermes/dashboard-home/`, and the SQLite database behind `.hermes/state.db`. -NemoClaw uses SQLite's online backup API and restores the database through SQLite instead of copying a live raw database file. +Hermes snapshots include `SOUL.md`, the Web Dashboard profile under `.hermes/dashboard-home/`, the SQLite database behind `.hermes/state.db`, and the default kanban board in `.hermes/kanban.db`. +NemoClaw uses SQLite's online backup API and restores those databases through SQLite instead of copying live raw database files. + +Kanban backup is limited to the backward-compatible default board in `kanban.db`. +Named boards, attachments, worker logs, scratch workspaces under `.hermes/kanban/`, and external directory or worktree targets are not included; back up that state separately. The dashboard profile includes `MEMORY.md` and `USER.md`. -The Hermes database can contain session metadata and message history needed for a faithful restore. +The Hermes state database can contain session metadata and message history needed for a faithful restore. Deep Agents snapshots include manifest-declared state under `/sandbox/.deepagents`, including skills and runtime state, while omitting credential-bearing user files. diff --git a/docs/manage-sandboxes/workspace-files.mdx b/docs/manage-sandboxes/workspace-files.mdx index 2389e31c31e..e913106556b 100644 --- a/docs/manage-sandboxes/workspace-files.mdx +++ b/docs/manage-sandboxes/workspace-files.mdx @@ -124,6 +124,7 @@ Runtime state, such as logs, memory, platform sessions, and the SQLite state dat | `/sandbox/.hermes/config.yaml` | NemoClaw-generated Hermes runtime configuration. | | `/sandbox/.hermes/.env` | NemoClaw-generated environment and messaging placeholders. | | `/sandbox/.hermes/state.db` | Hermes SQLite state database. | +| `/sandbox/.hermes/kanban.db` | Default Hermes kanban board database. NemoClaw snapshots preserve only this default board. | | `/sandbox/.hermes/dashboard-home/` | Hermes Web Dashboard profile, including `MEMORY.md` and `USER.md`. | | `/sandbox/.hermes/platforms/` | Messaging platform state, including QR-paired sessions such as WhatsApp. | | `/sandbox/.hermes/logs/` | Hermes runtime logs. | @@ -133,7 +134,8 @@ Runtime state, such as logs, memory, platform sessions, and the SQLite state dat Hermes state lives in the sandbox's persistent state volume, not in the container image alone. Normal restarts preserve that state. -Rebuilds and upgrades use NemoClaw's snapshot flow to preserve manifest-defined Hermes state, including `SOUL.md`, the Web Dashboard profile under `.hermes/dashboard-home/`, and the SQLite database behind `.hermes/state.db`. +Rebuilds and upgrades use NemoClaw's snapshot flow to preserve manifest-defined Hermes state, including `SOUL.md`, the Web Dashboard profile under `.hermes/dashboard-home/`, the SQLite database behind `.hermes/state.db`, and the default kanban board in `.hermes/kanban.db`. +Named boards, attachments, worker logs, scratch workspaces under `.hermes/kanban/`, and external directory or worktree targets are not included in the kanban backup. Running `$$nemoclaw destroy` deletes the sandbox and its persistent state volume. Back up important state before destroying a Hermes sandbox. diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 1e654da4228..ea98233e603 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -599,7 +599,8 @@ For OpenClaw, the backed-up paths include agents, extensions, workspace, skills, -For Hermes, the backed-up paths come from `agents/hermes/manifest.yaml`, including `/sandbox/.hermes` state such as memories, sessions, skills, plugins, cron, logs, plans, workspace, messaging platform state, and `runtime/state.db`. +For Hermes, the backed-up paths come from `agents/hermes/manifest.yaml`, including `/sandbox/.hermes` state such as memories, sessions, skills, plugins, cron, logs, plans, workspace, messaging platform state, `runtime/state.db`, and the default kanban board in `kanban.db`. +Kanban backup does not include named boards, attachments, worker logs, scratch workspaces under `kanban/`, or external directory or worktree targets. From 42116bfb5a170ccd5bf62f24c73ac6342be8bc8c Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 18 Jul 2026 10:05:49 -0700 Subject: [PATCH 8/8] test(hermes): cover kanban rebuild persistence --- test/e2e/live/rebuild-hermes.test.ts | 53 ++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/test/e2e/live/rebuild-hermes.test.ts b/test/e2e/live/rebuild-hermes.test.ts index a46dae51434..29993cc9176 100644 --- a/test/e2e/live/rebuild-hermes.test.ts +++ b/test/e2e/live/rebuild-hermes.test.ts @@ -62,6 +62,8 @@ SANDBOX_NAME.startsWith(TEST_SANDBOX_PREFIX) || const MARKER_FILE = "/sandbox/.hermes/memories/rebuild-marker.txt"; const MARKER_CONTENT = `REBUILD_HM_E2E_${Date.now()}`; +const KANBAN_TASK_TITLE = `NEMOCLAW_REBUILD_KANBAN_${Date.now()}`; +const EXCLUDED_KANBAN_FILE = "/sandbox/.hermes/kanban/excluded-rebuild-marker.txt"; const DISCORD_PLACEHOLDER = "openshell:resolve:env:DISCORD_BOT_TOKEN"; const DISCORD_FAKE_TOKEN = "test-fake-discord-token-rebuild-e2e"; const REGISTRY_FILE = path.join(os.homedir(), ".nemoclaw", "sandboxes.json"); @@ -736,6 +738,32 @@ test(STALE_BASE_REBUILD ); expectExitZero(writeMarker, "write Hermes marker"); + const seedKanban = await host.command( + "openshell", + [ + "sandbox", + "exec", + "--name", + SANDBOX_NAME, + "--", + "sh", + "-lc", + [ + "hermes kanban init", + `hermes kanban create ${shellQuote(KANBAN_TASK_TITLE)} --initial-status blocked --json`, + `mkdir -p ${shellQuote(path.dirname(EXCLUDED_KANBAN_FILE))}`, + `printf '%s' ${shellQuote(MARKER_CONTENT)} > ${shellQuote(EXCLUDED_KANBAN_FILE)}`, + ].join(" && "), + ], + { + artifactName: "phase-4-seed-hermes-kanban", + env: testEnv(apiKey), + redactionValues, + timeoutMs: OPENSHELL_TIMEOUT_MS, + }, + ); + expectExitZero(seedKanban, "seed Hermes default kanban board"); + const preEnv = await host.command( "openshell", ["sandbox", "exec", "--name", SANDBOX_NAME, "--", "cat", "/sandbox/.hermes/.env"], @@ -878,6 +906,31 @@ test(STALE_BASE_REBUILD `Hermes version output did not include expected release ${expectedVersion}: ${hermesVersionText}`, ); + const restoredKanban = await host.command( + "openshell", + ["sandbox", "exec", "--name", SANDBOX_NAME, "--", "hermes", "kanban", "list", "--json"], + { + artifactName: "phase-7-list-kanban-after-rebuild", + env: testEnv(apiKey), + redactionValues, + timeoutMs: OPENSHELL_TIMEOUT_MS, + }, + ); + expectExitZero(restoredKanban, "list Hermes kanban tasks after rebuild"); + expect(resultText(restoredKanban)).toContain(KANBAN_TASK_TITLE); + + const excludedKanbanState = await host.command( + "openshell", + ["sandbox", "exec", "--name", SANDBOX_NAME, "--", "test", "!", "-e", EXCLUDED_KANBAN_FILE], + { + artifactName: "phase-7-verify-excluded-kanban-state", + env: testEnv(apiKey), + redactionValues, + timeoutMs: OPENSHELL_TIMEOUT_MS, + }, + ); + expectExitZero(excludedKanbanState, "verify excluded Hermes kanban state was not restored"); + const restoredEnv = await host.command( "openshell", ["sandbox", "exec", "--name", SANDBOX_NAME, "--", "cat", "/sandbox/.hermes/.env"],