diff --git a/SKILL.md b/SKILL.md index 35ec1446..c6fcb86f 100644 --- a/SKILL.md +++ b/SKILL.md @@ -213,6 +213,15 @@ If argument starts with "remote disconnect": 2. Run: `bash ~/.agents/skills/agmsg/scripts/remote.sh disconnect ` 3. Show the output to the user. +If argument starts with "remote forget": +1. Parse the required ``. This permanently deletes that team's local + roster, history, keys, trust, and sync state, but never changes the server. +2. Do not add `--yes` yourself. Run: + `bash ~/.agents/skills/agmsg/scripts/remote.sh forget ` +3. The command requires the user to confirm in their terminal. If this agent + has no interactive terminal, show the deletion summary and tell the user to + rerun the displayed command directly; never bypass confirmation for them. + ### End-to-end encryption If argument starts with "key generate" followed by an optional team name: diff --git a/scripts/drivers/types/antigravity/template.md b/scripts/drivers/types/antigravity/template.md index 16d825ae..b6e6730a 100644 --- a/scripts/drivers/types/antigravity/template.md +++ b/scripts/drivers/types/antigravity/template.md @@ -169,6 +169,11 @@ If argument starts with "remote disconnect": 2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh disconnect ` 3. Show the output to the user. +If argument starts with "remote forget": +1. Parse the required ``. This permanently deletes that team's local roster, history, keys, trust, and sync state, but never changes the server. +2. Do not add `--yes` yourself. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh forget ` +3. The command requires the user to confirm in their terminal. If this agent has no interactive terminal, show the deletion summary and tell the user to rerun the displayed command directly; never bypass confirmation for them. + If argument starts with "key generate" followed by an optional team name: 1. Run: `~/.agents/skills/__SKILL_NAME__/scripts/key.sh generate []` 2. Show the full output to the user, including the mandatory key-backup notice — do not summarize it away. diff --git a/scripts/drivers/types/claude-code/template.md b/scripts/drivers/types/claude-code/template.md index 5fad2be5..672d7eaf 100644 --- a/scripts/drivers/types/claude-code/template.md +++ b/scripts/drivers/types/claude-code/template.md @@ -249,6 +249,11 @@ If argument starts with "remote disconnect": 2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh disconnect ` 3. Show the output to the user. +If argument starts with "remote forget": +1. Parse the required ``. This permanently deletes that team's local roster, history, keys, trust, and sync state, but never changes the server. +2. Do not add `--yes` yourself. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh forget ` +3. The command requires the user to confirm in their terminal. If this agent has no interactive terminal, show the deletion summary and tell the user to rerun the displayed command directly; never bypass confirmation for them. + If argument starts with "key generate" followed by an optional team name: 1. Run: `~/.agents/skills/__SKILL_NAME__/scripts/key.sh generate []` 2. Show the full output to the user, including the mandatory key-backup notice — do not summarize it away. diff --git a/scripts/drivers/types/codex/template.md b/scripts/drivers/types/codex/template.md index 98ac4087..8fd2341b 100644 --- a/scripts/drivers/types/codex/template.md +++ b/scripts/drivers/types/codex/template.md @@ -197,6 +197,11 @@ If argument starts with "remote disconnect": 2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh disconnect ` 3. Show the output to the user. +If argument starts with "remote forget": +1. Parse the required ``. This permanently deletes that team's local roster, history, keys, trust, and sync state, but never changes the server. +2. Do not add `--yes` yourself. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh forget ` +3. The command requires the user to confirm in their terminal. If this agent has no interactive terminal, show the deletion summary and tell the user to rerun the displayed command directly; never bypass confirmation for them. + If argument starts with "key generate" followed by an optional team name: 1. Run: `~/.agents/skills/__SKILL_NAME__/scripts/key.sh generate []` 2. Show the full output to the user, including the mandatory key-backup notice — do not summarize it away. diff --git a/scripts/drivers/types/copilot/template.md b/scripts/drivers/types/copilot/template.md index 2340cbcf..0b454709 100644 --- a/scripts/drivers/types/copilot/template.md +++ b/scripts/drivers/types/copilot/template.md @@ -169,6 +169,11 @@ If argument starts with "remote disconnect": 2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh disconnect ` 3. Show the output to the user. +If argument starts with "remote forget": +1. Parse the required ``. This permanently deletes that team's local roster, history, keys, trust, and sync state, but never changes the server. +2. Do not add `--yes` yourself. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh forget ` +3. The command requires the user to confirm in their terminal. If this agent has no interactive terminal, show the deletion summary and tell the user to rerun the displayed command directly; never bypass confirmation for them. + If argument starts with "key generate" followed by an optional team name: 1. Run: `~/.agents/skills/__SKILL_NAME__/scripts/key.sh generate []` 2. Show the full output to the user, including the mandatory key-backup notice — do not summarize it away. diff --git a/scripts/drivers/types/cursor/template.md b/scripts/drivers/types/cursor/template.md index 2ed80f07..98a595f8 100644 --- a/scripts/drivers/types/cursor/template.md +++ b/scripts/drivers/types/cursor/template.md @@ -172,6 +172,11 @@ If argument starts with "remote disconnect": 2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh disconnect ` 3. Show the output to the user. +If argument starts with "remote forget": +1. Parse the required ``. This permanently deletes that team's local roster, history, keys, trust, and sync state, but never changes the server. +2. Do not add `--yes` yourself. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh forget ` +3. The command requires the user to confirm in their terminal. If this agent has no interactive terminal, show the deletion summary and tell the user to rerun the displayed command directly; never bypass confirmation for them. + If argument starts with "key generate" followed by an optional team name: 1. Run: `~/.agents/skills/__SKILL_NAME__/scripts/key.sh generate []` 2. Show the full output to the user, including the mandatory key-backup notice — do not summarize it away. diff --git a/scripts/drivers/types/gemini/template.md b/scripts/drivers/types/gemini/template.md index ddf98775..521be3dc 100644 --- a/scripts/drivers/types/gemini/template.md +++ b/scripts/drivers/types/gemini/template.md @@ -169,6 +169,11 @@ If argument starts with "remote disconnect": 2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh disconnect ` 3. Show the output to the user. +If argument starts with "remote forget": +1. Parse the required ``. This permanently deletes that team's local roster, history, keys, trust, and sync state, but never changes the server. +2. Do not add `--yes` yourself. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh forget ` +3. The command requires the user to confirm in their terminal. If this agent has no interactive terminal, show the deletion summary and tell the user to rerun the displayed command directly; never bypass confirmation for them. + If argument starts with "key generate" followed by an optional team name: 1. Run: `~/.agents/skills/__SKILL_NAME__/scripts/key.sh generate []` 2. Show the full output to the user, including the mandatory key-backup notice — do not summarize it away. diff --git a/scripts/drivers/types/grok-build/template.md b/scripts/drivers/types/grok-build/template.md index f5828d3c..907ef069 100644 --- a/scripts/drivers/types/grok-build/template.md +++ b/scripts/drivers/types/grok-build/template.md @@ -200,6 +200,11 @@ If argument starts with "remote disconnect": 2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh disconnect ` 3. Show the output to the user. +If argument starts with "remote forget": +1. Parse the required ``. This permanently deletes that team's local roster, history, keys, trust, and sync state, but never changes the server. +2. Do not add `--yes` yourself. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh forget ` +3. The command requires the user to confirm in their terminal. If this agent has no interactive terminal, show the deletion summary and tell the user to rerun the displayed command directly; never bypass confirmation for them. + If argument starts with "key generate" followed by an optional team name: 1. Run: `~/.agents/skills/__SKILL_NAME__/scripts/key.sh generate []` 2. Show the full output to the user, including the mandatory key-backup notice — do not summarize it away. diff --git a/scripts/drivers/types/hermes/template.md b/scripts/drivers/types/hermes/template.md index 541fe9db..7d86afe0 100644 --- a/scripts/drivers/types/hermes/template.md +++ b/scripts/drivers/types/hermes/template.md @@ -157,6 +157,11 @@ If argument starts with "remote disconnect": 2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh disconnect ` 3. Show the output to the user. +If argument starts with "remote forget": +1. Parse the required ``. This permanently deletes that team's local roster, history, keys, trust, and sync state, but never changes the server. +2. Do not add `--yes` yourself. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh forget ` +3. The command requires the user to confirm in their terminal. If this agent has no interactive terminal, show the deletion summary and tell the user to rerun the displayed command directly; never bypass confirmation for them. + If argument starts with "key generate" followed by an optional team name: 1. Run: `~/.agents/skills/__SKILL_NAME__/scripts/key.sh generate []` 2. Show the full output to the user, including the mandatory key-backup notice — do not summarize it away. diff --git a/scripts/drivers/types/opencode/template.md b/scripts/drivers/types/opencode/template.md index af0eb439..75e4a92d 100644 --- a/scripts/drivers/types/opencode/template.md +++ b/scripts/drivers/types/opencode/template.md @@ -172,6 +172,11 @@ If argument starts with "remote disconnect": 2. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh disconnect ` 3. Show the output to the user. +If argument starts with "remote forget": +1. Parse the required ``. This permanently deletes that team's local roster, history, keys, trust, and sync state, but never changes the server. +2. Do not add `--yes` yourself. Run: `bash ~/.agents/skills/__SKILL_NAME__/scripts/remote.sh forget ` +3. The command requires the user to confirm in their terminal. If this agent has no interactive terminal, show the deletion summary and tell the user to rerun the displayed command directly; never bypass confirmation for them. + If argument starts with "key generate" followed by an optional team name: 1. Run: `~/.agents/skills/__SKILL_NAME__/scripts/key.sh generate []` 2. Show the full output to the user, including the mandatory key-backup notice — do not summarize it away. diff --git a/scripts/remote.sh b/scripts/remote.sh index 050fa5b1..5f7130e1 100644 --- a/scripts/remote.sh +++ b/scripts/remote.sh @@ -8,6 +8,7 @@ set -euo pipefail # [--confirm-digest ] # remote.sh status [] [--json] # remote.sh disconnect +# remote.sh forget [--yes] # # Team-scoped cloud/self-hosted sync connection. The OSS CLI never assumes or # defaults to a server, so is always required. `connect` registers a @@ -43,6 +44,13 @@ _agmsg_sqlesc() { printf %s "$1" | sed "s/'/''/g"; } _remote_team_config() { printf '%s' "$TEAMS_DIR/$1/config.json"; } _remote_cred_file() { printf '%s' "$CRED_ROOT/$1.json"; } +_remote_sync_config_file() { + local encoded + encoded="$(python3 -c \ + "import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=\"-_.!~*'()\"))" \ + "$1")" + printf '%s/remote-sync/%s.json' "$(agmsg_storage_dir)" "$encoded" +} # is spliced as a genuine SQL string literal below, NOT bound via # `.param set`: the sqlite3 shell's dot-command tokenizer does not honour SQL @@ -162,7 +170,7 @@ cmd_doctor() { else echo " [ ] python3 on PATH" echo - echo "'python3' is required for the remote control plane (connect/status/disconnect/pending) and was not found on this device. Install it, then retry:" + echo "'python3' is required for the remote control plane (connect/pull/status/disconnect/forget/pending) and was not found on this device. Install it, then retry:" echo " macOS (Homebrew): brew install python3" echo " macOS (Xcode tools): xcode-select --install" echo " Debian/Ubuntu: sudo apt install python3" @@ -356,7 +364,10 @@ _remote_local_disconnect() { # is spliced as a genuine SQL string literal, NOT bound via # `.param set` (same tokenizer caveat as `_remote_read_config_field` above). updated=$(agmsg_sqlite_mem \ - "SELECT json_set('$escaped', '\$.remote_binding.disconnected_at', '$(_agmsg_sqlesc "$disconnected_at")');") + "SELECT json_set('$escaped', + '\$.remote_binding.disconnected_at', '$(_agmsg_sqlesc "$disconnected_at")', + '\$.remote_binding.binding_revision', + coalesce(json_extract('$escaped', '\$.remote_binding.binding_revision'), 0) + 1);") agmsg_write_atomic "$cfg" "$updated" agmsg_lock_release } @@ -749,7 +760,9 @@ _remote_commit() { 'protocol_version', $protocol_version, 'capabilities', json('$caps_escaped'), 'connected_at', '$(_agmsg_sqlesc "$connected_at")', - 'disconnected_at', null + 'disconnected_at', null, + 'binding_revision', + coalesce(json_extract('$escaped', '\$.remote_binding.binding_revision'), 0) + 1 ));") agmsg_write_atomic "$cfg" "$updated" } @@ -938,7 +951,9 @@ cmd_pull() { 'cipher_profile', '$binding_cipher', 'capabilities', json('$caps_escaped'), 'connected_at', '$bind_at', - 'disconnected_at', null));") + 'disconnected_at', null, + 'binding_revision', + coalesce(json_extract('$escaped', '\$.remote_binding.binding_revision'), 0) + 1));") agmsg_write_atomic "$cfg" "$updated" agmsg_lock_release @@ -1351,6 +1366,8 @@ cmd_connect() { # the team_id is a value we minted ourselves. local connected_at updated connected_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + agmsg_lock_acquire "$TEAMS_DIR/$team" || exit 1 + cfg_escaped="$(sed "s/'/''/g" "$cfg")" updated=$(agmsg_sqlite_mem \ "SELECT json_set('$cfg_escaped', '\$.remote_binding', json_object( 'endpoint', '$(_agmsg_sqlesc "$endpoint")', @@ -1361,9 +1378,12 @@ cmd_connect() { 'cipher_profile', '$binding_cipher', 'capabilities', json('$resp_escaped'), 'connected_at', '$(_agmsg_sqlesc "$connected_at")', - 'disconnected_at', null + 'disconnected_at', null, + 'binding_revision', + coalesce(json_extract('$cfg_escaped', '\$.remote_binding.binding_revision'), 0) + 1 ));") agmsg_write_atomic "$cfg" "$updated" + agmsg_lock_release if [ "$e2ee" -eq 0 ] && ! _remote_binding_allows_cipher "$cfg" none; then echo "agmsg: this remote does not allow $binding_cipher; no sync engine was started." >&2 @@ -1618,15 +1638,169 @@ cmd_disconnect() { echo "Disconnected '$team'. Local sync state cleared; sends/reads continue locally." } +# --- forget --------------------------------------------------------------- + +cmd_forget() { + local team="" yes=0 positional=() + while [ $# -gt 0 ]; do + case "$1" in + --yes) yes=1; shift ;; + *) positional+=("$1"); shift ;; + esac + done + [ "${#positional[@]}" -eq 1 ] || { + echo "Usage: remote.sh forget [--yes] " >&2 + exit 1 + } + team="${positional[0]}" + agmsg_validate_team_name "$team" || exit 1 + + local team_dir cfg connected_at disconnected_at binding_before binding_current \ + binding_revision_before binding_revision_current escaped updated + team_dir="$TEAMS_DIR/$team" + cfg="$(_remote_team_config "$team")" + [ -f "$cfg" ] || { + echo "agmsg: team '$team' has no local remote binding to forget" >&2 + exit 1 + } + agmsg_lock_acquire "$team_dir" || exit 1 + connected_at="$(_remote_read_config_field "$cfg" '$.remote_binding.connected_at')" + disconnected_at="$(_remote_read_config_field "$cfg" '$.remote_binding.disconnected_at')" + binding_revision_before="$(_remote_read_config_field "$cfg" '$.remote_binding.binding_revision')" + if [ -z "$binding_revision_before" ] || [ "$binding_revision_before" = "null" ]; then + escaped="$(sed "s/'/''/g" "$cfg")" + updated="$(agmsg_sqlite_mem \ + "SELECT json_set('$escaped', '\$.remote_binding.binding_revision', 1);")" + agmsg_write_atomic "$cfg" "$updated" + binding_revision_before=1 + fi + binding_before="$(_remote_read_config_field "$cfg" '$.remote_binding')" + agmsg_lock_release + if [ -z "$connected_at" ] || [ "$connected_at" = "null" ]; then + echo "agmsg: team '$team' has never been connected" >&2 + exit 1 + fi + if [ -z "$disconnected_at" ] || [ "$disconnected_at" = "null" ]; then + echo "agmsg: team '$team' is still connected; run 'remote.sh disconnect $team' first" >&2 + exit 1 + fi + + # A successful remote connection owns this exact directory. Never resolve + # through agmsg_db_path here: a malformed or interrupted layout selection + # must not turn a team-scoped delete into deletion of the shared store. + local store_dir store_path event_count=0 tables + store_dir="$(agmsg_storage_dir)/teams/$team" + store_path="$store_dir/messages.db" + if [ -f "$store_path" ]; then + tables="$(agmsg_sqlite "$store_path" \ + "SELECT name FROM sqlite_master WHERE type='table';")" || { + echo "agmsg: cannot inspect team store '$store_path'; refusing to delete it" >&2 + exit 1 + } + if printf '%s\n' "$tables" | grep -qx events; then + event_count="$(agmsg_sqlite "$store_path" "SELECT COUNT(*) FROM events;")" + fi + if printf '%s\n' "$tables" | grep -qx messages; then + event_count="$((event_count + $(agmsg_sqlite "$store_path" "SELECT COUNT(*) FROM messages;")))" + fi + fi + + echo "This will forget local team '$team' from this machine." + echo "Store: $store_path" + echo "Events: $event_count" + echo "The server copy remains. Local roster, history, sync configuration, keys, and trust will be deleted." + + if [ "$yes" -ne 1 ]; then + if [ ! -t 0 ]; then + echo "agmsg: forget requires an interactive terminal or --yes" >&2 + exit 1 + fi + local answer="" + IFS= read -r -p "Type '$team' to confirm: " answer + if [ "$answer" != "$team" ]; then + echo "Forget cancelled." + return + fi + fi + + # Revalidate under the registry lock after the operator has confirmed. A + # reconnect racing the prompt must not have its new active binding deleted. + agmsg_lock_acquire "$team_dir" || exit 1 + binding_current="$(_remote_read_config_field "$cfg" '$.remote_binding')" + binding_revision_current="$(_remote_read_config_field "$cfg" '$.remote_binding.binding_revision')" + if [ "$binding_revision_current" != "$binding_revision_before" ] || + [ "$binding_current" != "$binding_before" ]; then + agmsg_lock_release + echo "agmsg: team '$team' changed while forget was waiting; nothing was deleted" >&2 + exit 1 + fi + disconnected_at="$(_remote_read_config_field "$cfg" '$.remote_binding.disconnected_at')" + if [ -z "$disconnected_at" ] || [ "$disconnected_at" = "null" ]; then + agmsg_lock_release + echo "agmsg: team '$team' became connected while forget was waiting; nothing was deleted" >&2 + exit 1 + fi + + local sync_config trust_root trust_file server_instance_id remote_team_id \ + protocol_version retired_dir + retired_dir="$TEAMS_DIR/.forget-$team.$$" + [ ! -e "$retired_dir" ] || { + agmsg_lock_release + echo "agmsg: temporary forget path already exists; nothing was deleted" >&2 + exit 1 + } + sync_config="$(_remote_sync_config_file "$team")" + trust_root="${AGMSG_SYNC_TRUST_DIR:-$CONNECTION_ROOT/run/remote-trust}" + server_instance_id="$(_remote_read_config_field "$cfg" '$.remote_binding.server_instance_id')" + remote_team_id="$(_remote_read_config_field "$cfg" '$.remote_binding.remote_team_id')" + protocol_version="$(_remote_read_config_field "$cfg" '$.remote_binding.protocol_version')" + if ! [[ "$server_instance_id" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ ]] || + ! [[ "$remote_team_id" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ ]] || + [ "$protocol_version" != "1" ]; then + agmsg_lock_release + echo "agmsg: team '$team' has an invalid remote binding; refusing to derive deletion paths from it" >&2 + exit 1 + fi + trust_file="$trust_root/age-v1-$server_instance_id-$remote_team_id-v$protocol_version.json" + + _remote_sync_engine_stop "$team" + rm -f "$sync_config" "$(_remote_cred_file "$team")" \ + "$CONNECTION_ROOT/run/remote-sync.$team.log" + local other_cfg trust_referenced=0 + for other_cfg in "$TEAMS_DIR"/*/config.json; do + [ -f "$other_cfg" ] || continue + [ "$other_cfg" = "$cfg" ] && continue + if [ "$(_remote_read_config_field "$other_cfg" '$.remote_binding.server_instance_id')" = "$server_instance_id" ] && + [ "$(_remote_read_config_field "$other_cfg" '$.remote_binding.remote_team_id')" = "$remote_team_id" ] && + [ "$(_remote_read_config_field "$other_cfg" '$.remote_binding.protocol_version')" = "$protocol_version" ]; then + trust_referenced=1 + break + fi + done + [ "$trust_referenced" -eq 1 ] || rm -f "$trust_file" + [ ! -d "$CRED_ROOT/$team" ] || rm -r "$CRED_ROOT/$team" + [ ! -d "$store_dir" ] || rm -r "$store_dir" + + # Rename is the local commit point: after it, a fresh join may safely create + # the same display name without racing deletion of the forgotten registry. + mv "$team_dir" "$retired_dir" + AGMSG_HELD_LOCKS="" + trap - EXIT INT TERM + rm -r "$retired_dir" + + echo "Forgot '$team' on this machine. The server copy was not changed." +} + case "${1:-}" in connect) shift; agmsg_require_python3 "remote connect" || exit 1; cmd_connect "$@" ;; pull) shift; agmsg_require_python3 "remote pull" || exit 1; cmd_pull "$@" ;; unlock) shift; agmsg_require_python3 "remote unlock" || exit 1; cmd_unlock "$@" ;; status) shift; agmsg_require_python3 "remote status" || exit 1; cmd_status "$@" ;; disconnect) shift; agmsg_require_python3 "remote disconnect" || exit 1; cmd_disconnect "$@" ;; + forget) shift; agmsg_require_python3 "remote forget" || exit 1; cmd_forget "$@" ;; doctor) shift; cmd_doctor "$@" ;; pending) shift; agmsg_require_python3 "remote pending" || exit 1; cmd_pending "$@" ;; *) - echo "Usage: remote.sh ..." >&2 + echo "Usage: remote.sh ..." >&2 exit 1 ;; esac diff --git a/tests/test_remote_forget.bats b/tests/test_remote_forget.bats new file mode 100644 index 00000000..22768fd9 --- /dev/null +++ b/tests/test_remote_forget.bats @@ -0,0 +1,175 @@ +#!/usr/bin/env bats + +load test_helper + +SERVER_ID="018f0000-0000-7000-8000-000000000001" +TEAM_ID="018f0000-0000-7000-8000-000000000002" + +setup() { + setup_test_env + bash "$SCRIPTS/join.sh" testteam alice claude-code /tmp/project-a + + local cfg="$TEST_SKILL_DIR/teams/testteam/config.json" escaped updated + escaped="$(sed "s/'/''/g" "$cfg")" + updated="$(sqlite_mem " + SELECT json_set('$escaped', + '\$.drivers.layout', 'per-team', + '\$.remote_binding', json_object( + 'endpoint', 'https://remote.example', + 'server_instance_id', '$SERVER_ID', + 'remote_team_id', '$TEAM_ID', + 'remote_team_name', 'testteam', + 'protocol_version', 1, + 'capabilities', json_object('write_allowed_ciphers', json_array('none')), + 'connected_at', '2026-07-30T00:00:00Z', + 'disconnected_at', '2026-07-30T00:01:00Z', + 'binding_revision', 1 + ));")" + printf '%s\n' "$updated" > "$cfg" + + mkdir -p "$TEST_SKILL_DIR/db/teams/testteam" + sqlite3 "$TEST_SKILL_DIR/db/teams/testteam/messages.db" <<'SQL' +CREATE TABLE events ( + seq INTEGER PRIMARY KEY, + type TEXT NOT NULL, + team TEXT NOT NULL +); +INSERT INTO events(seq,type,team) VALUES + (1,'message_sent','testteam'), + (2,'member_joined','testteam'); +CREATE TABLE messages (id INTEGER PRIMARY KEY); +INSERT INTO messages(id) VALUES (1); +SQL +} + +teardown() { + teardown_test_env +} + +set_disconnected_at() { + local value="$1" cfg="$TEST_SKILL_DIR/teams/testteam/config.json" escaped updated + escaped="$(sed "s/'/''/g" "$cfg")" + if [ "$value" = null ]; then + updated="$(sqlite_mem "SELECT json_set('$escaped', '\$.remote_binding.disconnected_at', null);")" + else + updated="$(sqlite_mem "SELECT json_set('$escaped', '\$.remote_binding.disconnected_at', '$value');")" + fi + printf '%s\n' "$updated" > "$cfg" +} + +@test "forget: refuses an active binding before deleting anything" { + set_disconnected_at null + local store="$TEST_SKILL_DIR/db/teams/testteam/messages.db" + + run bash "$SCRIPTS/remote.sh" forget --yes testteam + [ "$status" -ne 0 ] + [[ "$output" == *"still connected"* ]] + [[ "$output" == *"remote.sh disconnect testteam"* ]] + [ -f "$TEST_SKILL_DIR/teams/testteam/config.json" ] + [ -f "$store" ] +} + +@test "forget: shows its scope and rejects noninteractive deletion without --yes" { + local store="$TEST_SKILL_DIR/db/teams/testteam/messages.db" + + run bash "$SCRIPTS/remote.sh" forget testteam + [ "$status" -ne 0 ] + [[ "$output" == *"Store: $store"* ]] + [[ "$output" == *"Events: 3"* ]] + [[ "$output" == *"The server copy remains."* ]] + [[ "$output" == *"requires an interactive terminal or --yes"* ]] + [ -f "$TEST_SKILL_DIR/teams/testteam/config.json" ] + [ -f "$store" ] +} + +@test "forget: rejects any binding ABA while confirmation is pending" { + local out="$TEST_SKILL_DIR/forget.out" cfg="$TEST_SKILL_DIR/teams/testteam/config.json" + run python3 - "$SCRIPTS/remote.sh" "$cfg" "$out" <<'PY' +import json, os, pty, subprocess, sys + +remote, cfg, out = sys.argv[1:] +master, slave = pty.openpty() +proc = subprocess.Popen( + ["bash", remote, "forget", "testteam"], + stdin=slave, stdout=slave, stderr=slave, close_fds=True) +os.close(slave) +captured = b"" +prompt = b"Type 'testteam' to confirm:" +while prompt not in captured: + chunk = os.read(master, 4096) + if not chunk: + break + captured += chunk +if prompt not in captured: + proc.kill() + proc.wait() + sys.exit("forget confirmation prompt was not reached") + +with open(cfg, encoding="utf-8") as handle: + document = json.load(handle) +original = dict(document["remote_binding"]) +document["remote_binding"]["endpoint"] = "https://changed.example" +document["remote_binding"]["binding_revision"] = 2 +with open(cfg, "w", encoding="utf-8") as handle: + json.dump(document, handle) + handle.write("\n") +document["remote_binding"] = original +document["remote_binding"]["binding_revision"] = 3 +with open(cfg, "w", encoding="utf-8") as handle: + json.dump(document, handle) + handle.write("\n") + +os.write(master, b"testteam\n") +while True: + try: + chunk = os.read(master, 4096) + except OSError: + break + if not chunk: + break + captured += chunk +status = proc.wait() +with open(out, "wb") as handle: + handle.write(captured) +sys.exit(0 if status != 0 else "forget accepted an ABA binding") +PY + [ "$status" -eq 0 ] + grep -q "changed while forget was waiting" "$out" + [ -f "$cfg" ] + [ -d "$TEST_SKILL_DIR/db/teams/testteam" ] +} + +@test "forget: removes the complete local team without contacting the server" { + local store_dir="$TEST_SKILL_DIR/db/teams/testteam" + local trust_file="$TEST_SKILL_DIR/run/remote-trust/age-v1-$SERVER_ID-$TEAM_ID-v1.json" + + mkdir -p "$TEST_SKILL_DIR/db/remote-sync" \ + "$TEST_SKILL_DIR/run/remote-credentials/testteam" \ + "$TEST_SKILL_DIR/run/remote-trust" + printf '%s\n' '{}' > "$TEST_SKILL_DIR/db/remote-sync/testteam.json" + printf '%s\n' 'identity' > "$TEST_SKILL_DIR/run/remote-credentials/testteam/0.key" + printf '%s\n' '{}' > "$trust_file" + printf '%s\n' 'old engine output' > "$TEST_SKILL_DIR/run/remote-sync.testteam.log" + + run bash "$SCRIPTS/remote.sh" forget --yes testteam + [ "$status" -eq 0 ] + [[ "$output" == *"The server copy was not changed."* ]] + [ ! -d "$TEST_SKILL_DIR/teams/testteam" ] + [ ! -d "$store_dir" ] + [ ! -e "$TEST_SKILL_DIR/db/remote-sync/testteam.json" ] + [ ! -d "$TEST_SKILL_DIR/run/remote-credentials/testteam" ] + [ ! -e "$trust_file" ] + [ ! -e "$TEST_SKILL_DIR/run/remote-sync.testteam.log" ] +} + +@test "forget: preserves trust referenced by another local team" { + local trust_file="$TEST_SKILL_DIR/run/remote-trust/age-v1-$SERVER_ID-$TEAM_ID-v1.json" + bash "$SCRIPTS/join.sh" alias alice claude-code /tmp/project-b + cp "$TEST_SKILL_DIR/teams/testteam/config.json" "$TEST_SKILL_DIR/teams/alias/config.json" + mkdir -p "$TEST_SKILL_DIR/run/remote-trust" + printf '%s\n' '{}' > "$trust_file" + + run bash "$SCRIPTS/remote.sh" forget --yes testteam + [ "$status" -eq 0 ] + [ -f "$trust_file" ] +}