From 45e3840a05f6ffb279311b34dee26c9e3aed1ca9 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sat, 14 Mar 2026 21:53:21 -0400 Subject: [PATCH 1/4] ops: add state backup/restore command for droplet migration --- README.md | 12 ++ bin/baudbot | 2 + bin/baudbot.test.sh | 32 +++ bin/state.sh | 419 ++++++++++++++++++++++++++++++++++++ bin/state.test.sh | 125 +++++++++++ bin/test.sh | 1 + docs/operations.md | 24 +++ test/shell-scripts.test.mjs | 3 + 8 files changed, 618 insertions(+) create mode 100755 bin/state.sh create mode 100755 bin/state.test.sh diff --git a/README.md b/README.md index cce8b3c..f1c98ad 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,18 @@ 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 +``` + 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..f91272d 100644 --- a/bin/baudbot.test.sh +++ b/bin/baudbot.test.sh @@ -130,6 +130,37 @@ EOF ) } +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/baudbot-state.out 2>&1; then + return 1 + fi + + out="$(cat /tmp/baudbot-state.out)" + rm -f /tmp/baudbot-state.out + printf '%s\n' "$out" | grep -q "requires root" + ) +} + test_restart_restarts_systemd() { ( set -euo pipefail @@ -191,6 +222,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..2301280 --- /dev/null +++ b/bin/state.sh @@ -0,0 +1,419 @@ +#!/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) +# - optional secrets (.config/.env and auth.json) + +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" +) + +STATE_SECRET_PATHS=( + ".config/.env" + ".pi/agent/auth.json" +) + +usage() { + cat <<'EOF' +Usage: + sudo baudbot state backup [ARCHIVE.zip] [--exclude-secrets] [--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) + - ~/.config/.env and ~/.pi/agent/auth.json (unless --exclude-secrets) + +Examples: + sudo baudbot state backup /tmp/baudbot-state.zip + sudo baudbot state backup --exclude-secrets + 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 include_secrets="$2" + 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 + + cat > "$metadata_file" </dev/null || true + + echo "✓ state backup created: $archive_path" + if [ "$include_secrets" = "1" ]; then + echo " includes secrets (.config/.env + auth.json)" + else + echo " excludes secrets" + fi +} + +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" + 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..09134e3 --- /dev/null +++ b/bin/state.test.sh @@ -0,0 +1,125 @@ +#!/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 '# 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_with_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" + grep -q "custom skill" "$target_home/.pi/agent/skills/custom-skill/SKILL.md" + grep -q "enabled" "$target_home/.pi/agent/subagents-state.json" + grep -q "ANTHROPIC_API_KEY" "$target_home/.config/.env" + grep -q "anthropic" "$target_home/.pi/agent/auth.json" + ) +} + +test_backup_excludes_secrets_flag() { + ( + 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-no-secrets.zip" + + seed_agent_state "$source_home" + + run_state "$source_home" backup "$archive" --exclude-secrets + run_state "$target_home" restore "$archive" + + [ -f "$target_home/.pi/agent/memory/operational.md" ] + [ ! -f "$target_home/.config/.env" ] + [ ! -f "$target_home/.pi/agent/auth.json" ] + ) +} + +echo "=== state backup/restore tests ===" +echo "" + +run_test "round-trip backup/restore includes secrets" test_round_trip_with_secrets +run_test "backup --exclude-secrets omits secret files" test_backup_excludes_secrets_flag + +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..5907e12 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -62,6 +62,30 @@ 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: +- Archive includes `~/.config/.env` and `~/.pi/agent/auth.json` by default. +- Treat archive files as sensitive secrets (store/encrypt/transfer accordingly). +- Use `--exclude-secrets` if you need a shareable archive. +- Restore refuses to run while the service is active. + ## 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(); + }); }); From c0ccf599e1c1a0c39756f85b0b36426a63ae531a Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sat, 14 Mar 2026 22:03:14 -0400 Subject: [PATCH 2/4] state: address PR review feedback on restore safety --- bin/baudbot.test.sh | 15 ++++++--------- bin/state.sh | 47 +++++++++++++++++++++++++++++++++++---------- bin/state.test.sh | 3 +++ 3 files changed, 46 insertions(+), 19 deletions(-) diff --git a/bin/baudbot.test.sh b/bin/baudbot.test.sh index f91272d..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,11 @@ 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" ) } @@ -151,12 +149,11 @@ fi EOF chmod +x "$fakebin/id" - if PATH="$fakebin:$PATH" BAUDBOT_ROOT="$REPO_ROOT" bash "$CLI" state backup >/tmp/baudbot-state.out 2>&1; then + if PATH="$fakebin:$PATH" BAUDBOT_ROOT="$REPO_ROOT" bash "$CLI" state backup >"$tmp/state.out" 2>&1; then return 1 fi - out="$(cat /tmp/baudbot-state.out)" - rm -f /tmp/baudbot-state.out + out="$(cat "$tmp/state.out")" printf '%s\n' "$out" | grep -q "requires root" ) } diff --git a/bin/state.sh b/bin/state.sh index 2301280..15936fe 100755 --- a/bin/state.sh +++ b/bin/state.sh @@ -117,16 +117,28 @@ write_metadata_file() { host_name="$(hostname 2>/dev/null || echo unknown)" fi - cat > "$metadata_file" <> 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}") @@ -191,11 +208,18 @@ with zipfile.ZipFile(archive_path, "r") as zip_file: 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 } @@ -372,6 +396,9 @@ if fmt != expected: 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 diff --git a/bin/state.test.sh b/bin/state.test.sh index 09134e3..ad766cd 100755 --- a/bin/state.test.sh +++ b/bin/state.test.sh @@ -54,6 +54,8 @@ seed_agent_state() { 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" @@ -81,6 +83,7 @@ test_round_trip_with_secrets() { 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/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" grep -q "ANTHROPIC_API_KEY" "$target_home/.config/.env" From e4788ada44702ecb060129099ed5293a33be4521 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sat, 14 Mar 2026 23:44:43 -0400 Subject: [PATCH 3/4] state: exclude secrets from archives by design --- README.md | 2 ++ bin/state.sh | 42 ++++++++++++------------------------------ bin/state.test.sh | 22 ++++++++++++---------- docs/operations.md | 5 ++--- 4 files changed, 28 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index f1c98ad..892f1e5 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,8 @@ 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/state.sh b/bin/state.sh index 15936fe..1b790fb 100755 --- a/bin/state.sh +++ b/bin/state.sh @@ -5,7 +5,7 @@ # - persistent memory (~/.pi/agent/memory) # - todos (~/.pi/todos) # - local runtime customizations (extensions/skills/subagents/settings) -# - optional secrets (.config/.env and auth.json) +# Secrets are intentionally excluded from state archives. SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" # shellcheck source=bin/lib/shell-common.sh @@ -28,15 +28,10 @@ STATE_PATHS=( ".pi/agent/subagents-state.json" ) -STATE_SECRET_PATHS=( - ".config/.env" - ".pi/agent/auth.json" -) - usage() { cat <<'EOF' Usage: - sudo baudbot state backup [ARCHIVE.zip] [--exclude-secrets] [--force] + sudo baudbot state backup [ARCHIVE.zip] [--force] sudo baudbot state restore [--restart] What gets backed up: @@ -47,11 +42,13 @@ What gets backed up: - ~/.pi/agent/skills - ~/.pi/agent/subagents - ~/.pi/agent/subagents-state.json (if present) - - ~/.config/.env and ~/.pi/agent/auth.json (unless --exclude-secrets) + +Never backed up (private by design): + - ~/.config/.env + - ~/.pi/agent/auth.json Examples: sudo baudbot state backup /tmp/baudbot-state.zip - sudo baudbot state backup --exclude-secrets sudo baudbot stop sudo baudbot state restore /tmp/baudbot-state.zip sudo baudbot start @@ -108,7 +105,6 @@ copy_path_if_present() { write_metadata_file() { local metadata_file="$1" - local include_secrets="$2" local now now="$(date -u +%Y-%m-%dT%H:%M:%SZ)" @@ -118,11 +114,11 @@ write_metadata_file() { fi require_python3 - python3 - "$metadata_file" "$STATE_FORMAT" "$now" "$host_name" "$BAUDBOT_AGENT_USER" "$BAUDBOT_AGENT_HOME" "$include_secrets" <<'PY' + 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, include_secrets = sys.argv[1:] +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( @@ -132,7 +128,7 @@ with open(metadata_path, "w", encoding="utf-8") as handle: "host": host_name, "agent_user": agent_user, "agent_home": agent_home, - "include_secrets": include_secrets == "1", + "secrets_included": False, }, handle, indent=2, @@ -248,7 +244,7 @@ restore_ownership_if_root() { return 0 fi - for rel_path in "${STATE_PATHS[@]}" "${STATE_SECRET_PATHS[@]}"; do + 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 @@ -258,7 +254,6 @@ restore_ownership_if_root() { cmd_backup() { local archive_raw="" local archive_path="" - local include_secrets="1" local overwrite="0" local tmp_dir="" local state_root="" @@ -267,9 +262,6 @@ cmd_backup() { while [ "$#" -gt 0 ]; do case "$1" in - --exclude-secrets) - include_secrets="0" - ;; --force) overwrite="1" ;; @@ -312,23 +304,13 @@ cmd_backup() { copy_path_if_present "$rel_path" "$payload_root" done - if [ "$include_secrets" = "1" ]; then - for rel_path in "${STATE_SECRET_PATHS[@]}"; do - copy_path_if_present "$rel_path" "$payload_root" - done - fi - - write_metadata_file "$state_root/metadata.json" "$include_secrets" + 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" - if [ "$include_secrets" = "1" ]; then - echo " includes secrets (.config/.env + auth.json)" - else - echo " excludes secrets" - fi + echo " secrets are excluded by design" } cmd_restore() { diff --git a/bin/state.test.sh b/bin/state.test.sh index ad766cd..2325308 100755 --- a/bin/state.test.sh +++ b/bin/state.test.sh @@ -62,7 +62,7 @@ seed_agent_state() { printf '{"anthropic":{"type":"oauth"}}\n' > "$agent_home/.pi/agent/auth.json" } -test_round_trip_with_secrets() { +test_round_trip_excludes_secrets() { ( set -euo pipefail local tmp source_home target_home archive @@ -86,12 +86,12 @@ test_round_trip_with_secrets() { [ "$(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" - grep -q "ANTHROPIC_API_KEY" "$target_home/.config/.env" - grep -q "anthropic" "$target_home/.pi/agent/auth.json" + [ ! -f "$target_home/.config/.env" ] + [ ! -f "$target_home/.pi/agent/auth.json" ] ) } -test_backup_excludes_secrets_flag() { +test_restore_does_not_overwrite_existing_secrets() { ( set -euo pipefail local tmp source_home target_home archive @@ -101,15 +101,17 @@ test_backup_excludes_secrets_flag() { source_home="$tmp/source-home" target_home="$tmp/target-home" - archive="$tmp/state-no-secrets.zip" + archive="$tmp/state.zip" seed_agent_state "$source_home" - run_state "$source_home" backup "$archive" --exclude-secrets + 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" - [ -f "$target_home/.pi/agent/memory/operational.md" ] - [ ! -f "$target_home/.config/.env" ] + grep -q "ANTHROPIC_API_KEY=keep-existing" "$target_home/.config/.env" [ ! -f "$target_home/.pi/agent/auth.json" ] ) } @@ -117,8 +119,8 @@ test_backup_excludes_secrets_flag() { echo "=== state backup/restore tests ===" echo "" -run_test "round-trip backup/restore includes secrets" test_round_trip_with_secrets -run_test "backup --exclude-secrets omits secret files" test_backup_excludes_secrets_flag +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 ===" diff --git a/docs/operations.md b/docs/operations.md index 5907e12..f5000a0 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -81,10 +81,9 @@ sudo baudbot start ``` Notes: -- Archive includes `~/.config/.env` and `~/.pi/agent/auth.json` by default. -- Treat archive files as sensitive secrets (store/encrypt/transfer accordingly). -- Use `--exclude-secrets` if you need a shareable archive. +- 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 From 23c67f9d924df27383802ce8067ed96e13fe37e8 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Mon, 16 Mar 2026 21:42:04 -0400 Subject: [PATCH 4/4] state: harden restored directory permissions --- bin/state.sh | 14 ++++++++++++++ bin/state.test.sh | 3 +++ 2 files changed, 17 insertions(+) diff --git a/bin/state.sh b/bin/state.sh index 1b790fb..6359216 100755 --- a/bin/state.sh +++ b/bin/state.sh @@ -223,6 +223,20 @@ 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" diff --git a/bin/state.test.sh b/bin/state.test.sh index 2325308..116bc34 100755 --- a/bin/state.test.sh +++ b/bin/state.test.sh @@ -83,6 +83,9 @@ test_round_trip_excludes_secrets() { 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"