mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
Adds `scripts/buzz-adopt-prod-agents.sh`, which copies the installed-DMG (production) agent records and owner identity into the dev app-data store so a dev build boots as the **same agents and same owner npub** as the installed app — surviving `just reset`. ## What it restores `just reset` wipes the two things Buzz cannot re-derive: - The agent **records** — `managed-agents.json`, `personas.json`, `teams.json`, and `agents/teams/`. - The **owner identity** nsec, written as `identity.key` (0600, atomic). Agent private keys and identity-key adoption then happen automatically on the next dev boot via the shipped in-app migrations. ## When to run it 1. Quit any running **dev** build. A running installed DMG is fine — the script treats it as a read-only source and only warns against creating/archiving agents or editing teams while it runs. It never kills processes. 2. Run `just reset`. 3. Before the first dev boot, run `bash scripts/buzz-adopt-prod-agents.sh`. - `--dry-run` prints every action with zero writes. - `--force` overwrites an already-populated dev store. - One keychain prompt is expected (reading the prod owner identity). 4. Start the dev build. First boot copies the agent keys prod→dev and adopts `identity.key`, then deletes it. For worktree launches, export `BUZZ_SHARE_IDENTITY=1` (and `BUZZ_PRIVATE_KEY`) or the worktree mints its own duplicate agents. ## Running-process check Only a running **dev** build blocks the run: the script atomically swaps the entire dev `agents/` directory and stages `identity.key` for the next dev boot to consume, so doing that under a live dev process is the corruption the check must prevent. A running installed DMG is allowed — the app writes `managed-agents.json` via tmp+atomic-rename, so it can never hand the script a torn file. Each matching `buzz-desktop` PID is classified by its true post-exec executable path (via `lsof -d txt`, which reports the running vnode with symlinks already resolved); the installed `/Applications/Buzz.app/Contents/MacOS/buzz-desktop` path is the only allow. Anything else still alive blocks fail-closed; a PID that exits before its path can be resolved is ignored. ## Requirements Requires `python3` when a dev `agents/` already exists — it drives the `renamex_np(RENAME_SWAP)` syscall bash cannot call. The script fails in preflight with a clear message if `python3` is absent, never mid-commit. ## Safety Prod is strictly read-only; symlinked or aliased prod/dev roots are refused before any write. The records/identity restore commits in one atomic operation (`renamex_np(RENAME_SWAP)` when a dev store exists, a single `mv` when absent) that fails closed — a crash or I/O error leaves `agents/` either fully old or fully new, and unrelated live dev state (`logs/`, `agent-pids/`, `global-agent-config.json`, retention stores) is preserved verbatim across the swap. This is a temporary measure while we trial it and decide whether to port the logic into the dev-build startup path. Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
480 lines
23 KiB
Bash
Executable File
480 lines
23 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
#
|
|
# buzz-adopt-prod-agents.sh — copy your installed (production) Buzz agent
|
|
# records + owner identity into the dev app-data store so a dev build boots as
|
|
# the SAME agents and the SAME owner npub as your installed DMG.
|
|
#
|
|
# WHY
|
|
# `just reset` deletes the entire dev app-data dir (agent records included),
|
|
# every buzz-desktop-dev keychain entry, and the `_dev_migration_v1` marker.
|
|
# The next dev boot sees an empty store, so key-less records mint fresh
|
|
# keypairs against the prod relay → duplicate agent instances, and a fresh
|
|
# owner key → auth-tag split-brain. This script restores the two things the
|
|
# reset wipes that Buzz cannot re-derive on its own: the agent RECORDS and
|
|
# the OWNER identity nsec. Everything else the app repairs itself on next boot
|
|
# (see WHAT THIS DOES NOT DO).
|
|
#
|
|
# WHEN
|
|
# Run AFTER `just reset`, BEFORE the first dev boot (`just production` / dev).
|
|
# Any running DEV build must be closed; the installed DMG may stay open (it is
|
|
# only a read-only source here — see preflight 1a). Idempotent: refuses to
|
|
# clobber a non-empty dev store unless --force.
|
|
#
|
|
# WHAT THIS DOES NOT DO (verified against block/buzz origin/main)
|
|
# - It does NOT touch the buzz-desktop-dev keychain. Agent private keys are
|
|
# copied prod→dev automatically at next dev boot by
|
|
# `migrate_agent_keys_to_dev_service` (desktop/src-tauri/src/managed_agents/
|
|
# storage.rs:460): the reset wiped the `_dev_migration_v1` marker, so it
|
|
# re-runs and copies `agent:<pubkey>` keys for every pubkey in the restored
|
|
# records. It only ever failed to help before because the reset also wiped
|
|
# the RECORDS — step "records" below fixes exactly that.
|
|
# - The owner identity is written as a plaintext `identity.key` file, NOT into
|
|
# the dev keychain. Next dev boot's legacy-import path adopts it into the
|
|
# dev keyring (app_state.rs:583, `ReachableButEmpty` branch =
|
|
# the post-reset empty-dev-keyring state) with the crash-safe
|
|
# marker-before-delete ordering already shipped. The plaintext file exists
|
|
# only until that first boot consumes it.
|
|
#
|
|
# FLAGS
|
|
# --dry-run Print every action; write nothing.
|
|
# --force Proceed even if the dev store is non-empty (overwrites records
|
|
# and identity.key). Never bypasses the running-dev-build check.
|
|
# -h|--help This header.
|
|
#
|
|
# PATH SAFETY
|
|
# The prod store is strictly read-only — nothing under prod is ever created,
|
|
# read-modified, or deleted. Prod and dev are hardcoded canonical constants,
|
|
# so there is no override path to canonicalize; the only writes go to the
|
|
# fixed dev dir. As a guard against odd on-disk state, the script refuses to
|
|
# run if the dev store dir/agents subdir is a symlink, or if the prod root or
|
|
# any prod source (agents/, the record files, agents/teams/) is a symlink, or
|
|
# if prod and an existing dev resolve to the same or overlapping tree.
|
|
#
|
|
# ENV OVERRIDES (read-only against prod; exist so the identity read can be
|
|
# fixture-tested against a scratch keychain without touching real secrets)
|
|
# BUZZ_KEYCHAIN_SVC keychain service (default buzz-desktop)
|
|
# BUZZ_KEYCHAIN_ACCT keychain account (default secrets)
|
|
#
|
|
set -euo pipefail
|
|
|
|
# --- config -----------------------------------------------------------------
|
|
# Prod/dev app-data dirs are hardcoded canonical constants — the only paths a
|
|
# real invocation ever uses. Keychain service/account stay overridable because
|
|
# they are read-only against prod (used to fixture-test the identity read).
|
|
SUPPORT="$HOME/Library/Application Support"
|
|
PROD_DIR="$SUPPORT/xyz.block.buzz.app"
|
|
DEV_DIR="$SUPPORT/xyz.block.buzz.app.dev"
|
|
KEYCHAIN_SVC="${BUZZ_KEYCHAIN_SVC:-buzz-desktop}"
|
|
KEYCHAIN_ACCT="${BUZZ_KEYCHAIN_ACCT:-secrets}"
|
|
|
|
DRY_RUN=0
|
|
FORCE=0
|
|
|
|
RECORD_FILES=(managed-agents.json personas.json teams.json)
|
|
|
|
# --- helpers ----------------------------------------------------------------
|
|
say() { printf '%s\n' "$*"; }
|
|
info() { printf ' %s\n' "$*"; }
|
|
warn() { printf 'WARN: %s\n' "$*" >&2; }
|
|
die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
|
|
|
|
usage() { sed -n '2,/^set -euo/p' "$0" | sed '$d; s/^# \{0,1\}//'; exit 0; }
|
|
|
|
have() { command -v "$1" >/dev/null 2>&1; }
|
|
|
|
# Temp paths (files/dirs) to remove on exit. A cleanup FUNCTION consumes this
|
|
# array so no path is ever interpolated into trap source — a path containing
|
|
# an apostrophe or space cannot break the trap or leave a temp behind.
|
|
STAGE_TEMPS=()
|
|
# shellcheck disable=SC2329 # invoked indirectly via `trap cleanup_stages EXIT`
|
|
cleanup_stages() {
|
|
local p
|
|
for p in "${STAGE_TEMPS[@]:+${STAGE_TEMPS[@]}}"; do
|
|
[[ -e "$p" ]] && rm -rf "$p"
|
|
done
|
|
return 0 # never let the trap's last test override the script's exit status
|
|
}
|
|
trap cleanup_stages EXIT
|
|
|
|
# Create a mode-0600 staging file next to its final destination so the later
|
|
# rename is same-filesystem atomic. mktemp creates 0600 by default; the umask
|
|
# guards against a lenient inherited default. Echoes the temp path.
|
|
new_stage() { ( umask 077; mktemp "$1.stage.XXXXXX" ); }
|
|
|
|
# Atomically move a validated 0600 stage file over its destination. `mv -f`
|
|
# replaces the name (never writes through a symlink at $2) so an interrupted
|
|
# run can never leave a partial file at the live path.
|
|
finalize() { chmod 600 "$1"; mv -f "$1" "$2"; }
|
|
|
|
# Atomically exchange two existing directories via renamex_np(RENAME_SWAP) — the
|
|
# whole-bundle commit primitive. bash cannot call the syscall, so python3 drives
|
|
# it. Fail closed: any unavailability or nonzero syscall result exits nonzero so
|
|
# the caller aborts with the live store untouched (never a sequential fallback).
|
|
swap_dirs() {
|
|
python3 - "$1" "$2" <<'PY'
|
|
import ctypes, os, sys
|
|
a, b = sys.argv[1], sys.argv[2]
|
|
try:
|
|
libc = ctypes.CDLL("/usr/lib/libSystem.B.dylib", use_errno=True)
|
|
renamex_np = libc.renamex_np
|
|
except (OSError, AttributeError) as e:
|
|
sys.stderr.write("renamex_np unavailable: %s\n" % e); sys.exit(1)
|
|
renamex_np.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_uint]
|
|
RENAME_SWAP = 0x2
|
|
if renamex_np(os.fsencode(a), os.fsencode(b), RENAME_SWAP) != 0:
|
|
e = ctypes.get_errno()
|
|
sys.stderr.write("renamex_np(RENAME_SWAP) failed: errno %d (%s)\n" % (e, os.strerror(e)))
|
|
sys.exit(1)
|
|
PY
|
|
}
|
|
|
|
# Strip the volatile runtime_pid field from a managed-agents store (JSON array
|
|
# of ManagedAgentRecord; field verified at types.rs:317). Reads $1, writes $2.
|
|
# Nonzero exit on malformed input so callers can abort before the rename.
|
|
strip_runtime_pid() {
|
|
local src="$1" dst="$2"
|
|
if have jq; then
|
|
jq 'if type=="array" then map(del(.runtime_pid)) else del(.runtime_pid) end' "$src" > "$dst"
|
|
elif have python3; then
|
|
python3 - "$src" "$dst" <<'PY'
|
|
import json, sys
|
|
src, dst = sys.argv[1], sys.argv[2]
|
|
with open(src) as f:
|
|
data = json.load(f)
|
|
def strip(r):
|
|
if isinstance(r, dict):
|
|
r.pop("runtime_pid", None)
|
|
return r
|
|
data = [strip(r) for r in data] if isinstance(data, list) else strip(data)
|
|
with open(dst, "w") as f:
|
|
json.dump(data, f, indent=2)
|
|
PY
|
|
else
|
|
die "need jq or python3 to strip runtime_pid"
|
|
fi
|
|
}
|
|
|
|
# --- arg parse --------------------------------------------------------------
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--dry-run) DRY_RUN=1 ;;
|
|
--force) FORCE=1 ;;
|
|
-h|--help) usage ;;
|
|
*) die "unknown argument: $1 (see --help)" ;;
|
|
esac
|
|
shift
|
|
done
|
|
|
|
[[ $DRY_RUN -eq 1 ]] && say "== DRY RUN — no changes will be written =="
|
|
|
|
# --- step 1: preflight ------------------------------------------------------
|
|
say "[1/4] Preflight"
|
|
|
|
# 1a. Refuse if a running DEV build is detected; a running installed DMG is
|
|
# allowed (read-only detection; never kills). The main app binary is
|
|
# `buzz-desktop` for both the installed DMG
|
|
# (/Applications/Buzz.app/Contents/MacOS/buzz-desktop) and dev builds
|
|
# (target/<profile>/buzz-desktop via `tauri dev`). Match that path component
|
|
# exactly so sidecars/helpers (buzz, buzz-dev-mcp, buzz-agent) don't
|
|
# false-positive.
|
|
#
|
|
# WHY dev blocks but the DMG doesn't: step 2 atomically swaps the entire dev
|
|
# agents/ dir and step 3 stages identity.key for the next dev boot to
|
|
# consume — doing that under a live dev process is exactly the corruption
|
|
# the check must prevent. The DMG is only a read-only source: the app writes
|
|
# managed-agents.json via tmp+atomic-rename (managed_agents/storage.rs:603
|
|
# on origin/main), so a running DMG can never hand us a torn file. The only
|
|
# residual is cross-file skew if agents/teams are mutated in the DMG during
|
|
# the seconds this runs — a warn, self-healing on re-run, not a block.
|
|
#
|
|
# Classification per PID resolves the true post-exec executable path via
|
|
# `lsof -d txt` (the running vnode, symlinks already resolved by the kernel;
|
|
# argv[0]/ps comm can lie). Fail closed: the DMG path is the ONLY allow;
|
|
# anything else that is still alive blocks. A PID that exits between pgrep
|
|
# and resolution (path unresolvable AND process gone) is ignored — it is no
|
|
# longer running. A PID still alive but unresolvable (permissions, exotic
|
|
# state) blocks.
|
|
ALLOWED_PROD_EXE="/Applications/Buzz.app/Contents/MacOS/buzz-desktop"
|
|
|
|
# Echo a PID's true executable path (first txt-mapped vnode), or empty if none.
|
|
# lsof exits nonzero when the PID is gone; callers use `|| true` so a raced exit
|
|
# under `set -e` yields an empty path (handled as raced-exit) instead of aborting.
|
|
pid_exe_path() { lsof -a -p "$1" -d txt -Fn 2>/dev/null | awk '/^n/{print substr($0,2); exit}'; }
|
|
|
|
# True only for the documented clean no-match — the PID has genuinely exited.
|
|
# `ps -p` fails BOTH for a gone PID and for other errors, and `kill -0` fails
|
|
# both for ESRCH and EPERM (a live process the caller can't signal), so neither
|
|
# status alone distinguishes gone from alive-but-indeterminate. Classify by all
|
|
# three signals and fail closed: gone == the ONE documented clean no-match shape
|
|
# (macOS `ps` exits EXACTLY 1 with empty stdout and empty stderr). A live PID
|
|
# (incl. one we lack permission to signal) exits 0 and prints its pid; any other
|
|
# status, any stderr diagnostic, or a setup failure (mktemp/rm) is indeterminate
|
|
# and returns non-gone → blocking. `pid_gone` runs as an `elif` condition where
|
|
# `errexit` is suppressed, so it must classify failures explicitly rather than
|
|
# rely on `set -e` to abort — hence the explicit `|| return 1` on setup steps.
|
|
pid_gone() {
|
|
local out err rc
|
|
err="$(mktemp)" || return 1
|
|
if out="$(ps -p "$1" -o pid= 2>"$err")"; then rc=0; else rc=$?; fi
|
|
local had_err=0; [[ -s "$err" ]] && had_err=1
|
|
rm -f "$err" || return 1
|
|
[[ $rc -eq 1 && -z "$out" && $had_err -eq 0 ]]
|
|
}
|
|
|
|
running_pids="$(pgrep -f '/buzz-desktop( |$)' 2>/dev/null || true)"
|
|
dmg_running=0
|
|
dev_blocking=() # "pid:reason" for each PID that blocks the run
|
|
if [[ -n "$running_pids" ]]; then
|
|
# Canonicalize the allow-path once (resolve any symlink in the DMG bundle
|
|
# path) so a symlinked DMG binary still matches the resolved lsof vnode.
|
|
allowed_real="$(realpath "$ALLOWED_PROD_EXE" 2>/dev/null || printf '%s' "$ALLOWED_PROD_EXE")"
|
|
while IFS= read -r pid; do
|
|
[[ -n "$pid" ]] || continue
|
|
exe="$(pid_exe_path "$pid" || true)"
|
|
exe_real="$(realpath "$exe" 2>/dev/null || printf '%s' "$exe")"
|
|
if [[ -n "$exe" && "$exe_real" == "$allowed_real" ]]; then
|
|
dmg_running=1
|
|
elif pid_gone "$pid"; then
|
|
: # process exited between pgrep and resolution — no longer running, ignore
|
|
else
|
|
# Alive (or indeterminate) and not the allowed DMG path: dev build,
|
|
# unresolvable exe, or a liveness probe we can't trust — fail closed.
|
|
dev_blocking+=("$pid:${exe:-<unresolved>}")
|
|
fi
|
|
done <<< "$running_pids"
|
|
fi
|
|
if [[ ${#dev_blocking[@]} -gt 0 ]]; then
|
|
warn "A non-installed buzz-desktop process is running (dev build or unresolvable):"
|
|
for entry in "${dev_blocking[@]}"; do warn " PID ${entry%%:*} → ${entry#*:}"; done
|
|
warn "Quit any running dev build, then re-run. This script never kills processes."
|
|
exit 1
|
|
fi
|
|
if [[ $dmg_running -eq 1 ]]; then
|
|
info "installed DMG is running — allowed (read-only source)"
|
|
warn "Do NOT create/archive agents or edit teams in the DMG while this runs; the 4-file bundle is read as one snapshot."
|
|
else
|
|
info "no running Buzz process detected"
|
|
fi
|
|
|
|
# 1b. On-disk symlink refusals. The paths are fixed canonical constants, so
|
|
# there is no override spelling to canonicalize — but if something odd
|
|
# already exists at those paths (a symlinked dev store, or a prod source
|
|
# aliased elsewhere), following it would break the read-only promise or
|
|
# write outside the dev dir. Reject symlinks before any mkdir/write.
|
|
[[ -d "$PROD_DIR" ]] || die "prod app-data dir not found: $PROD_DIR"
|
|
# Prod root must be a real directory, never a symlink: a symlinked prod root
|
|
# could alias the dev tree, making source and destination the same path, so the
|
|
# read-only prod promise would break and the commit could destroy prod data.
|
|
[[ -L "$PROD_DIR" ]] && die "refusing: prod app-data dir is a symlink; must be a real directory."
|
|
# Dev write targets: mkdir -p / the directory exchange would otherwise follow a
|
|
# symlink here and write outside the dev dir.
|
|
for d in "$DEV_DIR" "$DEV_DIR/agents"; do
|
|
[[ -L "$d" ]] && die "refusing: $d is a symlink; write target must be a real directory."
|
|
done
|
|
# Prod source components: a symlinked prod/agents, record file, or teams/ dir
|
|
# could alias a dev target and be mutated or destroyed by the commit. Source
|
|
# must be real files/dirs. (Symlinked prod sources are also copied by value into
|
|
# the stage, silently importing whatever they point at — refuse instead.)
|
|
[[ -L "$PROD_DIR/agents" ]] && die "refusing: prod agents/ is a symlink; source must be a real directory."
|
|
for f in "${RECORD_FILES[@]}"; do
|
|
[[ -L "$PROD_DIR/agents/$f" ]] \
|
|
&& die "refusing: prod agents/$f is a symlink; source must be a real file."
|
|
done
|
|
[[ -L "$PROD_DIR/agents/teams" ]] \
|
|
&& die "refusing: prod agents/teams is a symlink; source must be a real directory."
|
|
# Physical distinctness: prod and dev must not resolve to the same tree or one
|
|
# inside the other. Both roots are now confirmed non-symlinks, but resolve
|
|
# physically anyway to catch any alias in an ancestor component. Dev is
|
|
# normally absent post-reset (fixed sibling constants can't overlap); only
|
|
# compare when it already exists (a forced re-run).
|
|
prod_phys="$(cd "$PROD_DIR" && pwd -P)"
|
|
if [[ -d "$DEV_DIR" ]]; then
|
|
dev_phys="$(cd "$DEV_DIR" && pwd -P)"
|
|
[[ "$prod_phys" == "$dev_phys" || "$prod_phys" == "$dev_phys"/* || "$dev_phys" == "$prod_phys"/* ]] \
|
|
&& die "refusing: prod and dev resolve to the same or overlapping directory (prod=$prod_phys dev=$dev_phys)."
|
|
fi
|
|
info "path checks OK (prod=$PROD_DIR dev=$DEV_DIR)"
|
|
|
|
# 1c. Prod store must exist and parse.
|
|
prod_agents="$PROD_DIR/agents/managed-agents.json"
|
|
[[ -f "$prod_agents" ]] || die "prod managed-agents.json not found: $prod_agents"
|
|
if have jq; then
|
|
jq empty "$prod_agents" 2>/dev/null || die "prod managed-agents.json is not valid JSON: $prod_agents"
|
|
elif have python3; then
|
|
python3 -c 'import json,sys; json.load(open(sys.argv[1]))' "$prod_agents" \
|
|
|| die "prod managed-agents.json is not valid JSON: $prod_agents"
|
|
fi
|
|
info "prod store OK: $prod_agents"
|
|
|
|
# 1d. Dev store must be absent/empty unless --force.
|
|
dev_agents="$DEV_DIR/agents/managed-agents.json"
|
|
if [[ -s "$dev_agents" ]]; then
|
|
if [[ $FORCE -eq 1 ]]; then
|
|
warn "dev store is non-empty; --force given, will overwrite: $dev_agents"
|
|
else
|
|
die "dev store already populated ($dev_agents). Re-run with --force to overwrite."
|
|
fi
|
|
else
|
|
info "dev store absent/empty: $dev_agents"
|
|
fi
|
|
|
|
# 1e. When a live agents/ already exists, the commit is an atomic directory
|
|
# exchange (renamex_np RENAME_SWAP), which only python3 can drive. Require
|
|
# it here so a missing interpreter fails preflight, never mid-commit.
|
|
if [[ $DRY_RUN -eq 0 && -d "$DEV_DIR/agents" ]] && ! have python3; then
|
|
die "python3 is required to atomically replace the existing dev agents/ directory."
|
|
fi
|
|
|
|
# --- step 2: restore records (exact bundle, one atomic commit) --------------
|
|
# The four boot-critical paths (managed-agents.json, personas.json, teams.json,
|
|
# teams/) are read as one related state, so the commit must be all-or-nothing.
|
|
# Phase 1 builds a COMPLETE replacement agents/ in a staging dir beside the live
|
|
# one — the prod bundle plus any unrelated live entries carried across verbatim,
|
|
# every transform validated, zero live-path mutation. Phase 2 commits it in ONE
|
|
# operation: a single rename when no live agents/ exists, otherwise one atomic
|
|
# renamex_np(RENAME_SWAP) exchange (fail closed, never a sequential fallback).
|
|
# A crash or I/O error therefore leaves agents/ either fully old or fully new.
|
|
say "[2/4] Agent records prod → dev"
|
|
if [[ $DRY_RUN -eq 1 ]]; then
|
|
info "[dry-run] stage a complete replacement agents/ beside the live dir"
|
|
for f in "${RECORD_FILES[@]}"; do
|
|
if [[ -f "$PROD_DIR/agents/$f" ]]; then
|
|
if [[ "$f" == managed-agents.json || "$f" == personas.json ]]; then
|
|
info "[dry-run] stage $f (strip runtime_pid, 0600)"
|
|
else
|
|
info "[dry-run] stage $f (0600)"
|
|
fi
|
|
elif [[ -e "$DEV_DIR/agents/$f" ]]; then
|
|
info "[dry-run] drop stale dev $f (absent in prod)"
|
|
else
|
|
info "skip (absent in prod): $f"
|
|
fi
|
|
done
|
|
if [[ -d "$PROD_DIR/agents/teams" ]]; then
|
|
info "[dry-run] stage agents/teams/"
|
|
elif [[ -e "$DEV_DIR/agents/teams" ]]; then
|
|
info "[dry-run] drop stale dev agents/teams/ (absent in prod)"
|
|
else
|
|
info "skip (absent in prod): agents/teams/"
|
|
fi
|
|
if [[ -d "$DEV_DIR/agents" ]]; then
|
|
info "[dry-run] commit via atomic directory exchange (existing dev agents/)"
|
|
else
|
|
info "[dry-run] commit via single rename (no existing dev agents/)"
|
|
fi
|
|
else
|
|
# Phase 1 — build the complete replacement in a staging dir on the dev
|
|
# filesystem. mktemp -d creates it 0700; record files are chmod 0600. Only
|
|
# $DEV_DIR (the stage parent) is created live — never agents/ — so a phase-1
|
|
# failure leaves no live agents/ scaffold behind.
|
|
mkdir -p "$DEV_DIR"
|
|
stage_agents="$(mktemp -d "$DEV_DIR/agents.stage.XXXXXX")"
|
|
STAGE_TEMPS+=("$stage_agents")
|
|
|
|
# Seed the stage with the entire live agents/ (a single checked copy — under
|
|
# `set -e` a partial copy aborts before any commit), then drop the four bundle
|
|
# names FROM THE STAGE so the prod overlay below replaces them cleanly. Any
|
|
# other live entry (logs/, agent-pids/, global-agent-config.json, retention
|
|
# stores, …) is carried across verbatim and preserved by the directory swap.
|
|
if [[ -d "$DEV_DIR/agents" ]]; then
|
|
cp -Rp "$DEV_DIR/agents/." "$stage_agents/"
|
|
rm -rf -- \
|
|
"$stage_agents/managed-agents.json" \
|
|
"$stage_agents/personas.json" \
|
|
"$stage_agents/teams.json" \
|
|
"$stage_agents/teams"
|
|
fi
|
|
|
|
# Stage the prod bundle. A record file absent in prod is simply not staged, so
|
|
# the swap drops any stale dev copy; the same holds for teams/.
|
|
for f in "${RECORD_FILES[@]}"; do
|
|
src="$PROD_DIR/agents/$f"
|
|
if [[ -f "$src" ]]; then
|
|
if [[ "$f" == managed-agents.json || "$f" == personas.json ]]; then
|
|
strip_runtime_pid "$src" "$stage_agents/$f" || die "failed to transform $f (dev unchanged)"
|
|
info "staged $f (runtime_pid stripped, mode 0600)"
|
|
else
|
|
cat "$src" > "$stage_agents/$f"
|
|
info "staged $f (mode 0600)"
|
|
fi
|
|
chmod 600 "$stage_agents/$f"
|
|
elif [[ -e "$DEV_DIR/agents/$f" ]]; then
|
|
info "dropping stale dev $f (absent in prod)"
|
|
else
|
|
info "skip (absent in prod): $f"
|
|
fi
|
|
done
|
|
if [[ -d "$PROD_DIR/agents/teams" ]]; then
|
|
cp -Rp "$PROD_DIR/agents/teams" "$stage_agents/teams"
|
|
info "staged agents/teams/"
|
|
elif [[ -e "$DEV_DIR/agents/teams" ]]; then
|
|
info "dropping stale dev agents/teams/ (absent in prod)"
|
|
else
|
|
info "skip (absent in prod): agents/teams/"
|
|
fi
|
|
|
|
# Phase 2 — commit the complete bundle in one atomic operation.
|
|
if [[ -d "$DEV_DIR/agents" ]]; then
|
|
swap_dirs "$stage_agents" "$DEV_DIR/agents" \
|
|
|| die "atomic directory exchange failed (dev agents/ unchanged)."
|
|
rm -rf "$stage_agents" # holds the swapped-out old tree after the exchange
|
|
info "committed agents/ via atomic exchange"
|
|
else
|
|
mv "$stage_agents" "$DEV_DIR/agents" # no live dir → single rename
|
|
info "committed agents/ via rename (no prior dev store)"
|
|
fi
|
|
fi
|
|
|
|
# --- step 3: owner identity → dev identity.key ------------------------------
|
|
say "[3/4] Owner identity prod keychain → dev identity.key"
|
|
info "reading prod keychain (service=$KEYCHAIN_SVC account=$KEYCHAIN_ACCT) — one keychain prompt expected"
|
|
blob="$(security find-generic-password -s "$KEYCHAIN_SVC" -a "$KEYCHAIN_ACCT" -w 2>/dev/null || true)"
|
|
if [[ -z "$blob" ]]; then
|
|
warn "no keychain entry for service=$KEYCHAIN_SVC account=$KEYCHAIN_ACCT — skipping owner identity."
|
|
warn "Records were still copied. Your dev build will mint a fresh owner key on boot."
|
|
else
|
|
# The blob is a JSON map; the owner nsec lives under the "identity" key and
|
|
# MUST be a non-empty JSON string (a number/object/null would be a corrupt
|
|
# key that boot rejects, falling through to a fresh identity — the exact
|
|
# split-brain this helper prevents).
|
|
if have jq; then
|
|
identity="$(printf '%s' "$blob" | jq -er 'if (.identity | type) == "string" and (.identity | length) > 0 then .identity else empty end' 2>/dev/null || true)"
|
|
else
|
|
identity="$(printf '%s' "$blob" | python3 -c 'import json,sys; v=json.load(sys.stdin).get("identity"); print(v if isinstance(v,str) and v else "")' 2>/dev/null || true)"
|
|
fi
|
|
if [[ -z "$identity" ]]; then
|
|
warn "keychain blob has no non-empty string \"identity\" entry — skipping owner identity (records still restored)."
|
|
else
|
|
keyfile="$DEV_DIR/identity.key"
|
|
if [[ $DRY_RUN -eq 1 ]]; then
|
|
info "[dry-run] write owner nsec → $keyfile (mode 0600, atomic) [secret not shown]"
|
|
else
|
|
mkdir -p "$DEV_DIR"
|
|
# Stage to a 0600 temp, then atomic rename — never truncate the live key
|
|
# path, so an interruption cannot leave a partial/corrupt identity.key.
|
|
stage="$(new_stage "$keyfile")"
|
|
STAGE_TEMPS+=("$stage")
|
|
printf '%s' "$identity" > "$stage"
|
|
finalize "$stage" "$keyfile"
|
|
info "wrote $keyfile (mode 0600) — dev boot adopts it into the dev keyring, then deletes it"
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# --- step 4: closing checklist ----------------------------------------------
|
|
say "[4/4] Done. Next steps"
|
|
cat <<EOF
|
|
1. Start your dev build (\`just production\` or \`just dev\`).
|
|
- First boot re-runs the agent-key migration → ONE prod-keychain prompt
|
|
(copies agent:<pubkey> keys buzz-desktop → buzz-desktop-dev).
|
|
- First boot adopts identity.key into the dev keyring, then deletes it.
|
|
2. Worktree launches: worktree-suffixed dev dirs are symlinked to the
|
|
canonical dev dir by sync_shared_agent_data ONLY when BUZZ_SHARE_IDENTITY=1.
|
|
If you launch from a worktree, export BUZZ_SHARE_IDENTITY=1 (and
|
|
BUZZ_PRIVATE_KEY) or the worktree will mint its own duplicate agents.
|
|
EOF
|
|
if [[ "${BUZZ_SHARE_IDENTITY:-}" != "1" ]]; then
|
|
say " NOTE: BUZZ_SHARE_IDENTITY is not set in this shell — set it for worktree launches."
|
|
fi
|
|
exit 0
|