diff --git a/README.md b/README.md index cce8b3c..892f1e5 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,20 @@ sudo baudbot env set ANTHROPIC_API_KEY # or: sudo baudbot env set OPENAI_API_KEY sk-... --restart ``` +Migrating droplets and want to keep memory/todos/custom runtime state? + +```bash +# old host +sudo baudbot state backup /tmp/baudbot-state.zip + +# new host +sudo baudbot stop +sudo baudbot state restore /tmp/baudbot-state.zip +sudo baudbot start +``` + +State archives intentionally exclude secrets (`~/.config/.env`, `~/.pi/agent/auth.json`), so reconfigure secrets on the new host. + See [CONFIGURATION.md](CONFIGURATION.md) for required environment variables and secret setup. ## The Slack broker (optional) diff --git a/bin/baudbot b/bin/baudbot index 1cfa282..bb91783 100755 --- a/bin/baudbot +++ b/bin/baudbot @@ -158,6 +158,7 @@ usage() { echo " test Run test suite" echo " update Build/test in temp checkout, publish git-free release, deploy" echo " rollback Re-deploy previous (or specified) git-free release snapshot" + echo " state Backup/restore agent state (memory, todos, customizations)" echo " uninstall Remove everything" echo "" echo -e "${BOLD}Options:${RESET}" @@ -422,6 +423,7 @@ register_command "audit" "exec" "$BAUDBOT_ROOT/bin/security-audit.sh" "0" "0" "" register_command "test" "exec" "$BAUDBOT_ROOT/bin/test.sh" "0" "0" "" register_command "update" "exec" "$BAUDBOT_ROOT/bin/update-release.sh" "1" "0" "" register_command "rollback" "exec" "$BAUDBOT_ROOT/bin/rollback-release.sh" "1" "0" "" +register_command "state" "exec" "$BAUDBOT_ROOT/bin/state.sh" "1" "0" "" register_command "uninstall" "exec" "$BAUDBOT_ROOT/bin/uninstall.sh" "1" "0" "" register_command "doctor" "exec" "$BAUDBOT_ROOT/bin/doctor.sh" "0" "0" "" register_command "subagents" "exec" "$BAUDBOT_ROOT/bin/subagents.sh" "0" "0" "" diff --git a/bin/baudbot.test.sh b/bin/baudbot.test.sh index 0cb940d..44d7146 100644 --- a/bin/baudbot.test.sh +++ b/bin/baudbot.test.sh @@ -89,12 +89,11 @@ fi EOF chmod +x "$fakebin/id" - if PATH="$fakebin:$PATH" BAUDBOT_ROOT="$REPO_ROOT" bash "$CLI" debug >/tmp/baudbot-debug.out 2>&1; then + if PATH="$fakebin:$PATH" BAUDBOT_ROOT="$REPO_ROOT" bash "$CLI" debug >"$tmp/debug.out" 2>&1; then return 1 fi - out="$(cat /tmp/baudbot-debug.out)" - rm -f /tmp/baudbot-debug.out + out="$(cat "$tmp/debug.out")" printf '%s\n' "$out" | grep -q "requires root" ) } @@ -120,12 +119,41 @@ fi EOF chmod +x "$fakebin/id" - if PATH="$fakebin:$PATH" BAUDBOT_ROOT="$REPO_ROOT" bash "$CLI" broker register >/tmp/baudbot-broker.out 2>&1; then + if PATH="$fakebin:$PATH" BAUDBOT_ROOT="$REPO_ROOT" bash "$CLI" broker register >"$tmp/broker.out" 2>&1; then return 1 fi - out="$(cat /tmp/baudbot-broker.out)" - rm -f /tmp/baudbot-broker.out + out="$(cat "$tmp/broker.out")" + printf '%s\n' "$out" | grep -q "requires root" + ) +} + +test_state_requires_root() { + ( + set -euo pipefail + local tmp fakebin out + tmp="$(mktemp -d /tmp/baudbot-cli-test.XXXXXX)" + trap 'rm -rf "$tmp"' EXIT + + mkdir -p "$tmp/fakebin" + fakebin="$tmp/fakebin" + cat > "$fakebin/id" <<'EOF' +#!/bin/bash +if [ "${1:-}" = "-u" ]; then + echo 1000 +elif [ "${1:-}" = "-un" ]; then + echo tester +else + /usr/bin/id "$@" +fi +EOF + chmod +x "$fakebin/id" + + if PATH="$fakebin:$PATH" BAUDBOT_ROOT="$REPO_ROOT" bash "$CLI" state backup >"$tmp/state.out" 2>&1; then + return 1 + fi + + out="$(cat "$tmp/state.out")" printf '%s\n' "$out" | grep -q "requires root" ) } @@ -191,6 +219,7 @@ run_test "version reads package.json" test_version_uses_package_json run_test "status dispatches via runtime module" test_status_dispatches_via_runtime_module run_test "debug requires root" test_debug_requires_root run_test "broker register requires root" test_broker_register_requires_root +run_test "state command requires root" test_state_requires_root run_test "restart restarts systemd" test_restart_restarts_systemd echo "" diff --git a/bin/state.sh b/bin/state.sh new file mode 100755 index 0000000..6359216 --- /dev/null +++ b/bin/state.sh @@ -0,0 +1,442 @@ +#!/bin/bash +# Baudbot state archive helper. +# +# Creates/restores a zip archive for durable agent state, including: +# - persistent memory (~/.pi/agent/memory) +# - todos (~/.pi/todos) +# - local runtime customizations (extensions/skills/subagents/settings) +# Secrets are intentionally excluded from state archives. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=bin/lib/shell-common.sh +source "$SCRIPT_DIR/lib/shell-common.sh" +# shellcheck source=bin/lib/paths-common.sh +source "$SCRIPT_DIR/lib/paths-common.sh" +bb_enable_strict_mode +bb_init_paths + +ALLOW_NON_ROOT="${BAUDBOT_STATE_ALLOW_NON_ROOT:-0}" +STATE_FORMAT="baudbot-state-v1" + +STATE_PATHS=( + ".pi/agent/memory" + ".pi/todos" + ".pi/agent/settings.json" + ".pi/agent/extensions" + ".pi/agent/skills" + ".pi/agent/subagents" + ".pi/agent/subagents-state.json" +) + +usage() { + cat <<'EOF' +Usage: + sudo baudbot state backup [ARCHIVE.zip] [--force] + sudo baudbot state restore [--restart] + +What gets backed up: + - ~/.pi/agent/memory + - ~/.pi/todos + - ~/.pi/agent/settings.json + - ~/.pi/agent/extensions + - ~/.pi/agent/skills + - ~/.pi/agent/subagents + - ~/.pi/agent/subagents-state.json (if present) + +Never backed up (private by design): + - ~/.config/.env + - ~/.pi/agent/auth.json + +Examples: + sudo baudbot state backup /tmp/baudbot-state.zip + sudo baudbot stop + sudo baudbot state restore /tmp/baudbot-state.zip + sudo baudbot start +EOF +} + +require_python3() { + command -v python3 >/dev/null 2>&1 || bb_die "python3 is required for zip archive handling" +} + +service_running() { + if [ "$(id -u)" -eq 0 ] && bb_has_systemd; then + systemctl is-active --quiet baudbot + return $? + fi + return 1 +} + +resolve_archive_path() { + local raw_path="${1:-}" + local path="" + + if [ -n "$raw_path" ]; then + path="$raw_path" + else + path="baudbot-state-$(date -u +%Y%m%d-%H%M%S).zip" + fi + + if [[ "$path" != *.zip ]]; then + path="${path}.zip" + fi + + if [[ "$path" != /* ]]; then + path="$PWD/$path" + fi + + echo "$path" +} + +copy_path_if_present() { + local rel_path="$1" + local payload_root="$2" + local source_path="$BAUDBOT_AGENT_HOME/$rel_path" + local target_path="$payload_root/$rel_path" + + if [ ! -e "$source_path" ]; then + return 0 + fi + + mkdir -p "$(dirname "$target_path")" + cp -a "$source_path" "$target_path" + bb_log "✓ included $rel_path" +} + +write_metadata_file() { + local metadata_file="$1" + local now + now="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + + local host_name="unknown" + if command -v hostname >/dev/null 2>&1; then + host_name="$(hostname 2>/dev/null || echo unknown)" + fi + + require_python3 + python3 - "$metadata_file" "$STATE_FORMAT" "$now" "$host_name" "$BAUDBOT_AGENT_USER" "$BAUDBOT_AGENT_HOME" <<'PY' +import json +import sys + +metadata_path, state_format, created_at, host_name, agent_user, agent_home = sys.argv[1:] + +with open(metadata_path, "w", encoding="utf-8") as handle: + json.dump( + { + "format": state_format, + "created_at": created_at, + "host": host_name, + "agent_user": agent_user, + "agent_home": agent_home, + "secrets_included": False, + }, + handle, + indent=2, + ) + handle.write("\n") +PY +} + +create_zip_archive() { + local source_dir="$1" + local archive_path="$2" + + require_python3 + + python3 - "$source_dir" "$archive_path" <<'PY' +import os +import sys +import zipfile + +source_dir = os.path.abspath(sys.argv[1]) +archive_path = os.path.abspath(sys.argv[2]) +archive_dir = os.path.dirname(archive_path) +source_parent = os.path.dirname(source_dir) + +if archive_dir: + os.makedirs(archive_dir, exist_ok=True) + +with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as zip_file: + for root, dirs, files in os.walk(source_dir): + dirs.sort() + files.sort() + for file_name in files: + full_path = os.path.join(root, file_name) + arc_name = os.path.relpath(full_path, source_parent) + zip_file.write(full_path, arc_name) +PY +} + +extract_zip_archive_safe() { + local archive_path="$1" + local extract_dir="$2" + + require_python3 + + python3 - "$archive_path" "$extract_dir" <<'PY' +import os +import pathlib +import stat +import sys +import zipfile + +archive_path = os.path.abspath(sys.argv[1]) +extract_dir = os.path.abspath(sys.argv[2]) +os.makedirs(extract_dir, exist_ok=True) + +with zipfile.ZipFile(archive_path, "r") as zip_file: + for member in zip_file.infolist(): + name = member.filename + if name.startswith("/") or "\x00" in name: + raise SystemExit(f"unsafe archive entry: {name}") + + member_mode = (member.external_attr >> 16) & 0o177777 + if stat.S_ISLNK(member_mode): + raise SystemExit(f"unsafe archive entry (symlink): {name}") + + parts = pathlib.PurePosixPath(name).parts + if any(part == ".." for part in parts): + raise SystemExit(f"unsafe archive entry: {name}") + + target_path = os.path.normpath(os.path.join(extract_dir, *parts)) + if not (target_path == extract_dir or target_path.startswith(extract_dir + os.sep)): + raise SystemExit(f"unsafe archive entry: {name}") + + if member.is_dir(): + os.makedirs(target_path, exist_ok=True) + dir_mode = member_mode & 0o7777 + if dir_mode: + os.chmod(target_path, dir_mode) + continue + + os.makedirs(os.path.dirname(target_path), exist_ok=True) + with zip_file.open(member, "r") as source, open(target_path, "wb") as target: + target.write(source.read()) + + file_mode = member_mode & 0o7777 + if file_mode: + os.chmod(target_path, file_mode) +PY +} + +restore_secure_permissions() { + local env_file="$BAUDBOT_AGENT_HOME/.config/.env" + local auth_file="$BAUDBOT_AGENT_HOME/.pi/agent/auth.json" + local settings_file="$BAUDBOT_AGENT_HOME/.pi/agent/settings.json" + local secure_dir="" + local secure_dirs=( + "$BAUDBOT_AGENT_HOME/.pi" + "$BAUDBOT_AGENT_HOME/.pi/agent" + "$BAUDBOT_AGENT_HOME/.pi/agent/memory" + "$BAUDBOT_AGENT_HOME/.pi/agent/subagents" + "$BAUDBOT_AGENT_HOME/.pi/todos" + ) + + for secure_dir in "${secure_dirs[@]}"; do + if [ -d "$secure_dir" ]; then + chmod 700 "$secure_dir" + fi + done + + if [ -f "$env_file" ]; then + chmod 600 "$env_file" + fi + + if [ -f "$auth_file" ]; then + chmod 600 "$auth_file" + fi + + if [ -f "$settings_file" ]; then + chmod 600 "$settings_file" + fi +} + +restore_ownership_if_root() { + local rel_path="" + + if [ "$(id -u)" -ne 0 ]; then + return 0 + fi + + for rel_path in "${STATE_PATHS[@]}"; do + if [ -e "$BAUDBOT_AGENT_HOME/$rel_path" ]; then + chown -R "$BAUDBOT_AGENT_USER:$BAUDBOT_AGENT_USER" "$BAUDBOT_AGENT_HOME/$rel_path" + fi + done +} + +cmd_backup() { + local archive_raw="" + local archive_path="" + local overwrite="0" + local tmp_dir="" + local state_root="" + local payload_root="" + local rel_path="" + + while [ "$#" -gt 0 ]; do + case "$1" in + --force) + overwrite="1" + ;; + --help|-h) + usage + return 0 + ;; + *) + if [ -n "$archive_raw" ]; then + bb_die "unexpected argument: $1" + fi + archive_raw="$1" + ;; + esac + shift + done + + archive_path="$(resolve_archive_path "$archive_raw")" + bb_require_root "baudbot state backup" "$ALLOW_NON_ROOT" + + [ -d "$BAUDBOT_AGENT_HOME" ] || bb_die "agent home does not exist: $BAUDBOT_AGENT_HOME" + + if [ -e "$archive_path" ] && [ "$overwrite" != "1" ]; then + bb_die "archive already exists: $archive_path (use --force to overwrite)" + fi + + if service_running; then + bb_warn "baudbot service is running; backup may miss in-flight writes" + bb_warn "for a fully consistent snapshot: sudo baudbot stop && sudo baudbot state backup ... && sudo baudbot start" + fi + + tmp_dir="$(mktemp -d /tmp/baudbot-state-backup.XXXXXX)" + trap 'rm -rf "${tmp_dir:-}"' RETURN + + state_root="$tmp_dir/baudbot-state" + payload_root="$state_root/agent-home" + mkdir -p "$payload_root" + + for rel_path in "${STATE_PATHS[@]}"; do + copy_path_if_present "$rel_path" "$payload_root" + done + + write_metadata_file "$state_root/metadata.json" + create_zip_archive "$state_root" "$archive_path" + + chmod 600 "$archive_path" 2>/dev/null || true + + echo "✓ state backup created: $archive_path" + echo " secrets are excluded by design" +} + +cmd_restore() { + local archive_raw="${1:-}" + local archive_path="" + local restart_service="0" + local tmp_dir="" + local state_root="" + local payload_root="" + + [ -n "$archive_raw" ] || bb_die "restore requires an archive path" + shift || true + + while [ "$#" -gt 0 ]; do + case "$1" in + --restart) + restart_service="1" + ;; + --help|-h) + usage + return 0 + ;; + *) + bb_die "unexpected argument: $1" + ;; + esac + shift + done + + archive_path="$(resolve_archive_path "$archive_raw")" + bb_require_root "baudbot state restore" "$ALLOW_NON_ROOT" + + [ -f "$archive_path" ] || bb_die "archive not found: $archive_path" + [ -d "$BAUDBOT_AGENT_HOME" ] || mkdir -p "$BAUDBOT_AGENT_HOME" + + if service_running; then + bb_die "baudbot service is running. Stop it first: sudo baudbot stop" + fi + + tmp_dir="$(mktemp -d /tmp/baudbot-state-restore.XXXXXX)" + trap 'rm -rf "${tmp_dir:-}"' RETURN + + extract_zip_archive_safe "$archive_path" "$tmp_dir" + + state_root="$tmp_dir/baudbot-state" + payload_root="$state_root/agent-home" + + [ -f "$state_root/metadata.json" ] || bb_die "invalid archive: missing metadata.json" + [ -d "$payload_root" ] || bb_die "invalid archive: missing agent-home payload" + + require_python3 + python3 - "$state_root/metadata.json" "$STATE_FORMAT" <<'PY' +import json +import sys + +metadata_path = sys.argv[1] +expected = sys.argv[2] + +with open(metadata_path, "r", encoding="utf-8") as handle: + data = json.load(handle) + +fmt = data.get("format") +if fmt != expected: + raise SystemExit(f"unsupported archive format: {fmt!r}") +PY + + mkdir -p "$BAUDBOT_AGENT_HOME" + if [ -n "$(ls -A "$BAUDBOT_AGENT_HOME" 2>/dev/null || true)" ]; then + bb_warn "agent home is not empty; existing files not in the archive will be preserved" + fi + cp -a "$payload_root/." "$BAUDBOT_AGENT_HOME/" + + restore_ownership_if_root + restore_secure_permissions + + echo "✓ state restored from: $archive_path" + + if [ "$restart_service" = "1" ]; then + if [ "$(id -u)" -eq 0 ] && bb_has_systemd; then + systemctl start baudbot + echo "✓ started baudbot service" + else + bb_warn "--restart requested, but systemd is not available" + fi + else + echo "Next step: sudo baudbot start" + fi +} + +main() { + local command="${1:-}" + + if [ -z "$command" ]; then + usage + exit 1 + fi + shift || true + + case "$command" in + backup) + cmd_backup "$@" + ;; + restore) + cmd_restore "$@" + ;; + --help|-h|help) + usage + ;; + *) + bb_die "unknown state subcommand: $command" + ;; + esac +} + +main "$@" diff --git a/bin/state.test.sh b/bin/state.test.sh new file mode 100755 index 0000000..116bc34 --- /dev/null +++ b/bin/state.test.sh @@ -0,0 +1,133 @@ +#!/bin/bash +# Tests for bin/state.sh backup/restore flow. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +STATE_SCRIPT="$SCRIPT_DIR/state.sh" + +TOTAL=0 +PASSED=0 +FAILED=0 + +run_test() { + local name="$1" + shift + local out + + TOTAL=$((TOTAL + 1)) + printf " %-45s " "$name" + + out="$(mktemp /tmp/baudbot-state-test-output.XXXXXX)" + if "$@" >"$out" 2>&1; then + echo "✓" + PASSED=$((PASSED + 1)) + else + echo "✗ FAILED" + tail -40 "$out" | sed 's/^/ /' + FAILED=$((FAILED + 1)) + fi + rm -f "$out" +} + +run_state() { + local agent_home="$1" + shift + + BAUDBOT_STATE_ALLOW_NON_ROOT=1 \ + BAUDBOT_AGENT_USER="$(id -un)" \ + BAUDBOT_AGENT_HOME="$agent_home" \ + BAUDBOT_HOME="$agent_home" \ + bash "$STATE_SCRIPT" "$@" +} + +seed_agent_state() { + local agent_home="$1" + mkdir -p "$agent_home/.pi/agent/memory" + mkdir -p "$agent_home/.pi/todos" + mkdir -p "$agent_home/.pi/agent/extensions/custom-ext" + mkdir -p "$agent_home/.pi/agent/skills/custom-skill" + mkdir -p "$agent_home/.pi/agent/subagents/custom-subagent" + mkdir -p "$agent_home/.config" + + printf 'memory-note\n' > "$agent_home/.pi/agent/memory/operational.md" + printf 'todo-item\n' > "$agent_home/.pi/todos/TODO-demo.md" + printf '{"theme":"dark"}\n' > "$agent_home/.pi/agent/settings.json" + printf 'export default true;\n' > "$agent_home/.pi/agent/extensions/custom-ext/index.ts" + printf '#!/bin/bash\necho custom\n' > "$agent_home/.pi/agent/extensions/custom-ext/run.sh" + chmod 755 "$agent_home/.pi/agent/extensions/custom-ext/run.sh" + printf '# custom skill\n' > "$agent_home/.pi/agent/skills/custom-skill/SKILL.md" + printf '{"enabled":true}\n' > "$agent_home/.pi/agent/subagents-state.json" + printf 'ANTHROPIC_API_KEY=sk-ant-test\n' > "$agent_home/.config/.env" + printf '{"anthropic":{"type":"oauth"}}\n' > "$agent_home/.pi/agent/auth.json" +} + +test_round_trip_excludes_secrets() { + ( + set -euo pipefail + local tmp source_home target_home archive + + tmp="$(mktemp -d /tmp/baudbot-state-test.XXXXXX)" + trap 'rm -rf "$tmp"' EXIT + + source_home="$tmp/source-home" + target_home="$tmp/target-home" + archive="$tmp/state.zip" + + seed_agent_state "$source_home" + + run_state "$source_home" backup "$archive" + run_state "$target_home" restore "$archive" + + grep -q "memory-note" "$target_home/.pi/agent/memory/operational.md" + grep -q "todo-item" "$target_home/.pi/todos/TODO-demo.md" + grep -q "theme" "$target_home/.pi/agent/settings.json" + grep -q "export default" "$target_home/.pi/agent/extensions/custom-ext/index.ts" + [ "$(stat -c '%a' "$target_home/.pi")" = "700" ] + [ "$(stat -c '%a' "$target_home/.pi/agent")" = "700" ] + [ "$(stat -c '%a' "$target_home/.pi/todos")" = "700" ] + [ "$(stat -c '%a' "$target_home/.pi/agent/extensions/custom-ext/run.sh")" = "755" ] + grep -q "custom skill" "$target_home/.pi/agent/skills/custom-skill/SKILL.md" + grep -q "enabled" "$target_home/.pi/agent/subagents-state.json" + [ ! -f "$target_home/.config/.env" ] + [ ! -f "$target_home/.pi/agent/auth.json" ] + ) +} + +test_restore_does_not_overwrite_existing_secrets() { + ( + set -euo pipefail + local tmp source_home target_home archive + + tmp="$(mktemp -d /tmp/baudbot-state-test.XXXXXX)" + trap 'rm -rf "$tmp"' EXIT + + source_home="$tmp/source-home" + target_home="$tmp/target-home" + archive="$tmp/state.zip" + + seed_agent_state "$source_home" + + mkdir -p "$target_home/.config" + printf 'ANTHROPIC_API_KEY=keep-existing\n' > "$target_home/.config/.env" + + run_state "$source_home" backup "$archive" + run_state "$target_home" restore "$archive" + + grep -q "ANTHROPIC_API_KEY=keep-existing" "$target_home/.config/.env" + [ ! -f "$target_home/.pi/agent/auth.json" ] + ) +} + +echo "=== state backup/restore tests ===" +echo "" + +run_test "round-trip backup/restore excludes secrets" test_round_trip_excludes_secrets +run_test "restore keeps existing target secrets" test_restore_does_not_overwrite_existing_secrets + +echo "" +echo "=== $PASSED/$TOTAL passed, $FAILED failed ===" + +if [ "$FAILED" -gt 0 ]; then + exit 1 +fi diff --git a/bin/test.sh b/bin/test.sh index f670513..ddf13d8 100755 --- a/bin/test.sh +++ b/bin/test.sh @@ -81,6 +81,7 @@ run_shell_tests() { run "manifest integrity" bash bin/verify-manifest.test.sh run "config flow" bash bin/config.test.sh run "subagents cli" bash bin/subagents.test.sh + run "state backup" bash bin/state.test.sh run "deploy lib helpers" bash bin/lib/deploy-common.test.sh run "doctor lib helpers" bash bin/lib/doctor-common.test.sh run "update release flow" bash bin/update-release.test.sh diff --git a/docs/operations.md b/docs/operations.md index e85e209..f5000a0 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -62,6 +62,29 @@ Provision with a pinned pi version (optional): BAUDBOT_PI_VERSION=0.52.12 baudbot install ``` +## State backup / restore (droplet migration) + +Use state archives to move persistent runtime data (memory, todos, local customizations) between droplets. + +```bash +# On old droplet +sudo baudbot state backup /tmp/baudbot-state.zip + +# Copy archive off-host (example) +scp root@old-droplet:/tmp/baudbot-state.zip . +scp ./baudbot-state.zip root@new-droplet:/tmp/ + +# On new droplet (after install + deploy) +sudo baudbot stop +sudo baudbot state restore /tmp/baudbot-state.zip +sudo baudbot start +``` + +Notes: +- Archives intentionally exclude secrets (`~/.config/.env`, `~/.pi/agent/auth.json`). +- Restore refuses to run while the service is active. +- Reconfigure secrets on the new host via `sudo baudbot config` / `sudo baudbot env` / `sudo baudbot login`. + ## Updating API keys after install ```bash diff --git a/test/shell-scripts.test.mjs b/test/shell-scripts.test.mjs index c37d88b..7102d09 100644 --- a/test/shell-scripts.test.mjs +++ b/test/shell-scripts.test.mjs @@ -71,4 +71,7 @@ describe("shell script test suites", () => { expect(() => runScript("bin/subagents.test.sh")).not.toThrow(); }); + it("state backup/restore", () => { + expect(() => runScript("bin/state.test.sh")).not.toThrow(); + }); });