diff --git a/docs/remote-setup.md b/docs/remote-setup.md index 15a87d8f4..f09e82aed 100644 --- a/docs/remote-setup.md +++ b/docs/remote-setup.md @@ -122,6 +122,16 @@ external tool reads the database file directly, resolve the team's new path instead of continuing to use the shared database path. Ask the agent for the team's store path, or use the command in [Reference](#reference). +A team already connected somewhere else — say you are trying this server with a +team you use against the hosted one — can be pointed here with the same +command. The binding it had is not lost: it is kept under `previous_bindings` +in the team's `config.json`, and `remote status` lists each replaced server +(host only, with the time it was replaced — the full endpoint is not printed, +because for a hosted endpoint the path embeds the access token). The local +sync state and keys for the old server also stay on disk, so reconnecting to +the full endpoint stored in the config restores the old binding and picks +that state back up. + **If you connected with `--e2ee`, export the handoff bundle now**, while you are still on the machine that holds the key: diff --git a/scripts/internal/remote-sync.mjs b/scripts/internal/remote-sync.mjs index 893650b5e..274efc785 100755 --- a/scripts/internal/remote-sync.mjs +++ b/scripts/internal/remote-sync.mjs @@ -438,6 +438,20 @@ function zoneRefusal(host) { } export function validateEndpoint(rawEndpoint) { + // The WHATWG parser DELETES ASCII tab and newline (and trims leading and + // trailing C0/space) before parsing, so a URL carrying them can validate + // while the raw string -- which is what gets stored in the binding and + // later read back line-wise by consumers such as `remote status` -- still + // contains them. The premise of this validator (stated above) is that what + // is written in the URL is where the connection goes; a byte the parser + // silently removes breaks that premise, so it is refused rather than + // repaired (#849 review). + if (/[\u0000-\u001f\u007f]/.test(rawEndpoint)) { + return rejected( + "--endpoint must not contain control characters " + + "(tab, newline, any byte below 0x20, or DEL)", + ); + } const authority = rawAuthority(rawEndpoint); if (authority === undefined) { return rejected("--endpoint must start with https:// (or http:// to a private IP address)"); diff --git a/scripts/remote.sh b/scripts/remote.sh index ddc20f96e..975aea276 100644 --- a/scripts/remote.sh +++ b/scripts/remote.sh @@ -551,13 +551,78 @@ _remote_http_get_json() { printf '%s' "$curl_output" } +# _remote_archive_replaced_binding \ +# +# +# Echoes the config document with the current $.remote_binding moved into +# $.previous_bindings, when — and only when — the binding being written names +# a DIFFERENT identity (#849). The caller passes the SQL-escaped document and +# re-escapes what comes back before splicing it into its own write. +# +# EVERY site that replaces $.remote_binding wholesale must run its document +# through this first. There are two such writers — _remote_write_binding +# below, and cmd_pull's bind-after-bootstrap write — and the non-destruction +# invariant of #849 holds at the writer boundary only if both archive. (The +# binding_revision-only touch-ups elsewhere replace nothing and are not +# writers in this sense.) +# +# One entry per (server_instance_id, remote_team_id, protocol_version): an +# entry for the identity being archived is replaced by the newer copy, and an +# entry matching the identity being written becomes the live binding again +# and leaves the archive. The array is therefore bounded by the number of +# distinct such identity tuples this team has ever been bound to -- one per +# server in the common case, more if the same server re-registers the team +# or the protocol version moves -- never by how often the team moved +# between them. +# +# `capabilities` is dropped from the archived copy: it is refetched on every +# connect, and an archived copy would be the one stale snapshot nobody +# re-reads. Restoring a previous binding is a reconnect to its endpoint -- +# which refetches -- never a copy of the archived object back into +# $.remote_binding. +# +# A current binding with no server_instance_id never completed a +# registration; there is no partition behind it to point back to, so it is +# replaced without being archived, same as before. +_remote_archive_replaced_binding() { + local cfg_escaped="$1" new_instance_sql new_team_sql pv="$4" stamp="$5" + new_instance_sql="$(_agmsg_sqlesc "$2")" + new_team_sql="$(_agmsg_sqlesc "$3")" + agmsg_sqlite_mem \ + "WITH cfg(doc) AS (SELECT '$cfg_escaped'), + cur(b) AS (SELECT json_extract(doc, '\$.remote_binding') FROM cfg), + kept(arr) AS (SELECT coalesce(( + SELECT json_group_array(json(value)) + FROM cfg, json_each(coalesce(json_extract(cfg.doc, '\$.previous_bindings'), '[]')) + WHERE NOT (json_extract(value, '\$.server_instance_id') IS json_extract((SELECT b FROM cur), '\$.server_instance_id') + AND json_extract(value, '\$.remote_team_id') IS json_extract((SELECT b FROM cur), '\$.remote_team_id') + AND json_extract(value, '\$.protocol_version') IS json_extract((SELECT b FROM cur), '\$.protocol_version')) + AND NOT (json_extract(value, '\$.server_instance_id') IS '$new_instance_sql' + AND json_extract(value, '\$.remote_team_id') IS '$new_team_sql' + AND json_extract(value, '\$.protocol_version') IS $pv)), '[]')) + SELECT CASE + WHEN (SELECT b FROM cur) IS NOT NULL + AND json_extract((SELECT b FROM cur), '\$.server_instance_id') IS NOT NULL + AND NOT (json_extract((SELECT b FROM cur), '\$.server_instance_id') IS '$new_instance_sql' + AND json_extract((SELECT b FROM cur), '\$.remote_team_id') IS '$new_team_sql' + AND json_extract((SELECT b FROM cur), '\$.protocol_version') IS $pv) + THEN json_set(doc, '\$.previous_bindings', + json_insert((SELECT arr FROM kept), '\$[#]', + json(json_set(json_remove((SELECT b FROM cur), '\$.capabilities'), + '\$.replaced_at', '$(_agmsg_sqlesc "$stamp")')))) + ELSE doc + END FROM cfg;" +} + # _remote_write_binding # Records the binding on the team config from a capability snapshot. No # credential is stored: the snapshot holds nothing that cannot be fetched # again, and the team_id is a value we minted ourselves. # -# ONE writer for both the first connect and the adopt path below. Two copies of -# this object would drift, and the second copy is the one nobody re-reads. +# ONE writer for both the first connect and the adopt path below — but NOT +# for every path: cmd_pull binds after its bootstrap with a write of its own, +# which is why the archive step above is a shared primitive rather than a +# private step of this function. _remote_write_binding() { local cfg="$1" endpoint="$2" binding_cipher="$3" resp_file="$4" \ expected_binding_revision="${5:-}" @@ -592,6 +657,18 @@ _remote_write_binding() { fi fi cfg_escaped="$(sed "s/'/''/g" "$cfg")" + # A write that points the team at a DIFFERENT server must not orphan the + # binding it replaces (#849). The local sync rows and keys for the old server + # survive this write untouched -- they are keyed on (server_instance_id, + # remote_team_id, protocol_version) -- but the endpoint string in the binding + # is the only pointer back to them, so overwriting it strands data that is + # still on disk. The shared archive primitive above moves the current + # binding into $.previous_bindings, a sibling key the wholesale json_set on + # $.remote_binding never touches. + local archived_doc + archived_doc="$(_remote_archive_replaced_binding "$cfg_escaped" \ + "$server_instance_id" "$remote_team_id" "$protocol_version" "$connected_at")" + cfg_escaped="$(printf '%s' "$archived_doc" | sed "s/'/''/g")" updated=$(agmsg_sqlite_mem \ "SELECT json_set('$cfg_escaped', '\$.remote_binding', json_object( 'endpoint', '$(_agmsg_sqlesc "$endpoint")', @@ -1112,9 +1189,17 @@ cmd_pull() { case "$pulled_protocol" in ''|*[!0-9]*) echo "agmsg: server answered with an invalid protocol version" >&2; exit 1 ;; esac agmsg_lock_acquire "$TEAMS_DIR/$team" || exit 1 - local bind_at escaped caps_escaped updated + local bind_at escaped caps_escaped updated archived_doc bind_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" escaped=$(sed "s/'/''/g" "$cfg") + # This is the second wholesale writer of $.remote_binding (#849): a pull + # into a team that already holds a binding to a DIFFERENT server would + # otherwise replace it with no way back. Same archive primitive as + # _remote_write_binding, so the non-destruction invariant holds at the + # writer boundary, not just on the connect path. + archived_doc="$(_remote_archive_replaced_binding "$escaped" \ + "$pulled_sid" "$pulled_id" "$pulled_protocol" "$bind_at")" + escaped="$(printf '%s' "$archived_doc" | sed "s/'/''/g")" caps_escaped=$(printf '%s' "$pulled_caps" | sed "s/'/''/g") updated=$(agmsg_sqlite_mem \ "SELECT json_set('$escaped', '\$.remote_binding', json_object( @@ -2436,6 +2521,42 @@ _remote_status_one() { else echo " encryption: none" fi + # What this team was bound to before, and when it was replaced (#849). The + # archived binding is the only pointer back to that server's local sync rows + # and keys, so a repair must not depend on the operator remembering the URL. + # + # Displayed through _remote_endpoint_display, which keeps scheme/host/port + # and DROPS the path -- for a hosted endpoint the path IS the capability. + # That means the printed form is NOT the value to reconnect with; the exact + # endpoint stays in the team's config, and the trailing line says so instead + # of pretending the display is it. + # + # One JSON object per row, NOT tab-separated fields: validateEndpoint now + # refuses raw control bytes, but a binding written by an OLDER version can + # hold an endpoint carrying them, and the archive keeps whatever the binding + # held. JSON escapes every byte below 0x20, so a row is one line whatever + # the endpoint contains; the per-field extraction below re-reads each row as + # JSON, and the printed values are additionally stripped of control bytes so + # nothing steers the terminal. + local prev_row prev_endpoint prev_replaced prev_any=0 + while IFS= read -r prev_row; do + [ -n "$prev_row" ] || continue + prev_endpoint="$(agmsg_sqlite_mem \ + "SELECT json_extract('$(printf '%s' "$prev_row" | sed "s/'/''/g")', '\$.e');")" + prev_replaced="$(agmsg_sqlite_mem \ + "SELECT json_extract('$(printf '%s' "$prev_row" | sed "s/'/''/g")', '\$.a');")" + prev_endpoint="$(_remote_endpoint_display "$prev_endpoint")" + prev_endpoint="${prev_endpoint//[[:cntrl:]]/}" + prev_replaced="${prev_replaced//[[:cntrl:]]/}" + prev_any=1 + echo " previous: was bound to $prev_endpoint until $prev_replaced" + done < <(agmsg_sqlite_mem \ + "SELECT json_object('e', json_extract(value, '\$.endpoint'), + 'a', coalesce(json_extract(value, '\$.replaced_at'), 'an unrecorded time')) + FROM json_each(coalesce(json_extract('$(sed "s/'/''/g" "$cfg")', '\$.previous_bindings'), '[]'));") + if [ "$prev_any" -eq 1 ]; then + echo " to restore one, reconnect to its full endpoint — it is kept under previous_bindings in this team's config.json, and is not printed here because it can embed the access token" + fi } # _remote_status_json_one — prints one JSONL object for 's diff --git a/tests/fixtures/endpoint-verdicts.jsonl b/tests/fixtures/endpoint-verdicts.jsonl index f08a6aa48..1a55628c1 100644 --- a/tests/fixtures/endpoint-verdicts.jsonl +++ b/tests/fixtures/endpoint-verdicts.jsonl @@ -69,3 +69,8 @@ {"endpoint": "https://example.com", "verdict": "allow", "why": "an ordinary name over https"} {"endpoint": "https://ex..ample.com", "verdict": "allow", "why": "doubled dot: odd, but both parsers accept it — not tightened beyond Node"} {"endpoint": "https://xn--wgv71a.example", "verdict": "allow", "why": "punycode name over https"} +{"endpoint": "https://example.com/t/agsy_token", "verdict": "allow", "why": "a hosted capability path: the path IS the endpoint, and archiving/restoring it must carry it verbatim (#849)"} +{"endpoint": "https://example.com/a\tb", "verdict": "deny", "why": "raw TAB in the path: the WHATWG parser deletes it, so the stored raw string and the parsed URL disagree — and line-wise readers of the stored value split on it (#849)"} +{"endpoint": "https://example.com/a\nb", "verdict": "deny", "why": "raw LF in the path: same class — one stored value becomes two lines for any line-framed reader (#849)"} +{"endpoint": "https://exam\rple.com/x", "verdict": "deny", "why": "raw CR: deleted by the parser before parsing, present in the stored raw string (#849)"} +{"endpoint": "\thttps://example.com", "verdict": "deny", "why": "leading TAB: trimmed by the parser, kept in the raw string (#849)"} diff --git a/tests/test_remote.bats b/tests/test_remote.bats index b5a888ced..546f1789b 100644 --- a/tests/test_remote.bats +++ b/tests/test_remote.bats @@ -64,6 +64,11 @@ cleanup_sync_engines() { teardown() { kill "$MOCK_SERVER_PID" 2>/dev/null || true wait "$MOCK_SERVER_PID" 2>/dev/null || true + if [ -n "${MOCK_SERVER_B_PID:-}" ]; then + kill "$MOCK_SERVER_B_PID" 2>/dev/null || true + wait "$MOCK_SERVER_B_PID" 2>/dev/null || true + MOCK_SERVER_B_PID="" + fi local cleanup_status=0 if ! cleanup_sync_engines "$TEST_SKILL_DIR" "primary"; then @@ -2968,3 +2973,271 @@ make_lock() { # make_lock [pid] [ "$status" -eq 0 ] grep -qF -- ".dotted: stale" <<<"$output" } + +# --- #849: pointing a team at a different server keeps the old binding ------ +# +# The local sync rows and keys for a server are keyed on (server_instance_id, +# remote_team_id, protocol_version) and survive a re-point untouched; the +# endpoint string in the binding is the only pointer back to them. Before +# #849, connect's wholesale json_set on $.remote_binding overwrote that +# pointer, orphaning data that was still on disk. + +start_second_mock_server() { + : > "$TEST_SKILL_DIR/server-b.port" + MOCK_TEAM_CIPHER_PROFILE="${MOCK_TEAM_CIPHER_PROFILE-age-v1}" \ + "$MOCK_PYTHON3" "$BATS_TEST_DIRNAME/helpers/mock_remote_server.py" 0 \ + "$TEST_SKILL_DIR/server-b.port" 2>"$TEST_SKILL_DIR/server-b.log" 3>&- & + MOCK_SERVER_B_PID=$! + wait_for_file_contains "$TEST_SKILL_DIR/server-b.port" '^[0-9][0-9]*$' + MOCK_PORT_B="$(cat "$TEST_SKILL_DIR/server-b.port")" + ENDPOINT_B="http://127.0.0.1:$MOCK_PORT_B" + # A different address is not a different server until the identity differs: + # rotate B's instance id so the two mocks are two servers, not one server + # reachable on two ports. + run curl -sS "$ENDPOINT_B/_test/rotate-server-id" + [ "$status" -eq 0 ] +} + +_config_json_path() { # $1 = team, $2 = full json path (no leading $.) + local cfg="$TEST_SKILL_DIR/teams/$1/config.json" resolved escaped + resolved="$(rf "$cfg")" + escaped="$(printf '%s' "$resolved" | sed "s/'/''/g")" + sqlite_mem "SELECT coalesce(json_extract(CAST(readfile('$escaped') AS TEXT), '\$.$2'), '');" +} + +_previous_count() { # $1 = team + local cfg="$TEST_SKILL_DIR/teams/$1/config.json" resolved escaped + resolved="$(rf "$cfg")" + escaped="$(printf '%s' "$resolved" | sed "s/'/''/g")" + sqlite_mem "SELECT coalesce(json_array_length(json_extract(CAST(readfile('$escaped') AS TEXT), '\$.previous_bindings')), 0);" +} + +@test "connect: pointing at a different server archives the replaced binding (#849)" { + run bash "$SCRIPTS/remote.sh" connect --endpoint "$ENDPOINT" testteam + [ "$status" -eq 0 ] + local instance_a revision_a + instance_a="$(_binding_field testteam server_instance_id)" + [ -n "$instance_a" ] + revision_a="$(_binding_field testteam binding_revision)" + + start_second_mock_server + run bash "$SCRIPTS/remote.sh" connect --endpoint "$ENDPOINT_B" testteam + [ "$status" -eq 0 ] + + # The live binding is B's. + [ "$(_binding_field testteam endpoint)" = "$ENDPOINT_B" ] + # A's binding is in the archive: same identity and endpoint it had, stamped + # with when it was replaced. Its capability snapshot is not carried along -- + # capabilities are refetched on every connect, and an archived copy would be + # the one stale snapshot nobody re-reads. + [ "$(_previous_count testteam)" = "1" ] + [ "$(_config_json_path testteam 'previous_bindings[0].endpoint')" = "$ENDPOINT" ] + [ "$(_config_json_path testteam 'previous_bindings[0].server_instance_id')" = "$instance_a" ] + [ "$(_config_json_path testteam 'previous_bindings[0].binding_revision')" = "$revision_a" ] + [ -n "$(_config_json_path testteam 'previous_bindings[0].replaced_at')" ] + [ "$(_config_json_path testteam 'previous_bindings[0].capabilities')" = "" ] +} + +@test "connect: a round trip A -> B -> A restores A's binding and its partition rows (#849)" { + run bash "$SCRIPTS/remote.sh" connect --endpoint "$ENDPOINT" testteam + [ "$status" -eq 0 ] + local instance_a team_id journal + instance_a="$(_binding_field testteam server_instance_id)" + [ -n "$instance_a" ] + team_id="$(_config_json_path testteam team_id)" + [ -n "$team_id" ] + + # A row in A's partition of the local journal: the archived binding is the + # pointer back to rows like this one. + journal="$TEST_SKILL_DIR/teams/testteam/roster.jsonl" + printf '%s\n' "{\"type\":\"roster_synced\",\"mutation_id\":\"m-849\",\"server_seq\":\"8\",\"wire_id\":\"550e8400-e29b-41d4-a716-446655440849\",\"server_instance_id\":\"$instance_a\",\"remote_team_id\":\"$team_id\"}" >> "$journal" + + start_second_mock_server + run bash "$SCRIPTS/remote.sh" connect --endpoint "$ENDPOINT_B" testteam + [ "$status" -eq 0 ] + + # The archived endpoint string is the value the repair needs; connect to it. + local archived_endpoint + archived_endpoint="$(_config_json_path testteam 'previous_bindings[0].endpoint')" + [ "$archived_endpoint" = "$ENDPOINT" ] + run bash "$SCRIPTS/remote.sh" connect --endpoint "$archived_endpoint" testteam + [ "$status" -eq 0 ] + + # The SAME server instance, adopted -- not a fresh registration. + [ "$(_binding_field testteam server_instance_id)" = "$instance_a" ] + # B is archived now; A's entry left the archive when A became current again. + [ "$(_previous_count testteam)" = "1" ] + [ "$(_config_json_path testteam 'previous_bindings[0].endpoint')" = "$ENDPOINT_B" ] + # The row in A's partition survived the whole dance. + grep -qF -- "\"server_instance_id\":\"$instance_a\"" "$journal" +} + +@test "connect: flipping between two servers keeps one archive entry per server (#849)" { + run bash "$SCRIPTS/remote.sh" connect --endpoint "$ENDPOINT" testteam + [ "$status" -eq 0 ] + local instance_a + instance_a="$(_binding_field testteam server_instance_id)" + start_second_mock_server + run bash "$SCRIPTS/remote.sh" connect --endpoint "$ENDPOINT_B" testteam + [ "$status" -eq 0 ] + run bash "$SCRIPTS/remote.sh" connect --endpoint "$ENDPOINT" testteam + [ "$status" -eq 0 ] + run bash "$SCRIPTS/remote.sh" connect --endpoint "$ENDPOINT_B" testteam + [ "$status" -eq 0 ] + # Two servers ever seen; the one not currently bound is the only entry, no + # matter how many times the team moved between them. + [ "$(_previous_count testteam)" = "1" ] + [ "$(_config_json_path testteam 'previous_bindings[0].server_instance_id')" = "$instance_a" ] +} + +@test "connect: reconnecting to the same server does not create an archive entry (#849)" { + run bash "$SCRIPTS/remote.sh" connect --endpoint "$ENDPOINT" testteam + [ "$status" -eq 0 ] + run bash "$SCRIPTS/remote.sh" connect --endpoint "$ENDPOINT" testteam + [ "$status" -eq 0 ] + [ "$(_config_json_path testteam previous_bindings)" = "" ] +} + +@test "remote status: shows the replaced endpoint after a re-point (#849)" { + run bash "$SCRIPTS/remote.sh" connect --endpoint "$ENDPOINT" testteam + [ "$status" -eq 0 ] + local endpoint_a="$ENDPOINT" + start_second_mock_server + run bash "$SCRIPTS/remote.sh" connect --endpoint "$ENDPOINT_B" testteam + [ "$status" -eq 0 ] + run bash "$SCRIPTS/remote.sh" status testteam + [ "$status" -eq 0 ] + grep -qF -- "previous: was bound to $endpoint_a" <<<"$output" + grep -qF -- "kept under previous_bindings in this team's config.json" <<<"$output" +} + +@test "remote status: a path-bearing archived endpoint is displayed host-only, kept exact in config (#849)" { + # For a hosted endpoint the path IS the capability. The archive must carry + # the exact value (it is the input to the restore), and status must not + # print it. + local cap; cap="$(_capability_endpoint)" + run bash "$SCRIPTS/remote.sh" connect --endpoint "$cap" testteam + [ "$status" -eq 0 ] + local instance_a + instance_a="$(_binding_field testteam server_instance_id)" + [ -n "$instance_a" ] + + start_second_mock_server + run bash "$SCRIPTS/remote.sh" connect --endpoint "$ENDPOINT_B" testteam + [ "$status" -eq 0 ] + + # The archive holds the exact path-bearing endpoint, verbatim. + [ "$(_config_json_path testteam 'previous_bindings[0].endpoint')" = "$cap" ] + + # Status shows the host-only form plus the pointer to the config -- and + # leaks neither the token nor the capability path. + assert_no_capability "previous: was bound to $ENDPOINT until" \ + bash "$SCRIPTS/remote.sh" status testteam + + # The archived exact value is the restore input: reconnecting with it + # re-anchors to the same server instance. + run bash "$SCRIPTS/remote.sh" connect --endpoint "$(_config_json_path testteam 'previous_bindings[0].endpoint')" testteam + [ "$status" -eq 0 ] + [ "$(_binding_field testteam server_instance_id)" = "$instance_a" ] +} + +@test "connect: refuses an endpoint carrying a raw control byte (#849)" { + # The WHATWG parser deletes TAB/LF before parsing, so without the explicit + # refusal the raw stored endpoint and the URL actually used would disagree + # -- and the stored value would split any line-framed reader. + run bash "$SCRIPTS/remote.sh" connect --endpoint "$ENDPOINT/t/pa th" testteam + [ "$status" -ne 0 ] + grep -qF -- "must not contain control characters" <<<"$output" + # Control for the refusal's reach: the same endpoint without the control + # byte is accepted (the refusal is about the byte, not the path). /t/ is + # the path shape the mock serves the API beneath. + run bash "$SCRIPTS/remote.sh" connect --endpoint "$ENDPOINT/t/path" testteam + [ "$status" -eq 0 ] +} + +@test "remote status: an archived endpoint stored by an older version with raw control bytes leaks nothing (#849)" { + # validateEndpoint refuses control bytes NOW, but a binding written by an + # older version can still hold them, and the archive keeps whatever the + # binding held. The fixture below is such a binding: a capability-style + # path with a raw TAB inside it, and a raw LF in the host region. + run bash "$SCRIPTS/remote.sh" connect --endpoint "$ENDPOINT" testteam + [ "$status" -eq 0 ] + local cfg="$TEST_SKILL_DIR/teams/testteam/config.json" + MOCK_PORT="$MOCK_PORT" python3 - "$cfg" <<'PY' +import json, os, sys +with open(sys.argv[1], encoding="utf-8") as handle: + document = json.load(handle) +port = os.environ["MOCK_PORT"] +document["remote_binding"]["endpoint"] = ( + "http://127.0.0.1\n:" + port + "/t/agsy_legacy_secret_849\ttail") +with open(sys.argv[1], "w", encoding="utf-8") as handle: + json.dump(document, handle) + handle.write("\n") +PY + + start_second_mock_server + run bash "$SCRIPTS/remote.sh" connect --endpoint "$ENDPOINT_B" testteam + [ "$status" -eq 0 ] + # The archive keeps the legacy value verbatim (it is still the only pointer + # back), control bytes and all. + [ "$(_config_json_path testteam 'previous_bindings[0].server_instance_id')" != "" ] + + run bash "$SCRIPTS/remote.sh" status testteam + [ "$status" -eq 0 ] + # No token, no capability path, exactly one previous line, and the + # replaced_at field intact beside it -- a raw TAB or LF in the stored + # endpoint must not split the line or bleed fragments into the output. + [ "$(grep -cF -- "previous: was bound to" <<<"$output")" -eq 1 ] + grep -qF -- "until 2" <<<"$output" + refute grep -qF -- "agsy_legacy_secret_849" <<<"$output" + refute grep -qF -- "/t/" <<<"$output" + refute grep -qF -- "tail" <<<"$output" +} + +@test "pull: re-pointing an already-bound team archives the replaced binding too (#849)" { + # $.remote_binding has TWO wholesale writers -- _remote_write_binding and + # cmd_pull's bind-after-bootstrap write. The invariant "a re-point does not + # destroy the binding it replaces" has to hold at the writer boundary, so + # this drives the pull entry, not connect. + # + # The team pull re-points: a local team with a binding to another server + # and NO history -- cmd_pull's own guard names this the continue case ("a + # local shell with nothing in it"). Built the way an older install leaves + # it: config in place, binding written directly. + local pull_team_id="018f3f7e-2222-7000-8000-000000000002" + bash "$SCRIPTS/join.sh" rebound alice claude-code /tmp/project-a + local cfg="$TEST_SKILL_DIR/teams/rebound/config.json" old_instance + old_instance="018f3f7e-9999-7000-8000-00000000dead" + OLD_INSTANCE="$old_instance" PULL_TEAM_ID="$pull_team_id" python3 - "$cfg" <<'PY' +import json, os, sys +with open(sys.argv[1], encoding="utf-8") as handle: + document = json.load(handle) +document["team_id"] = os.environ["PULL_TEAM_ID"] +document["remote_binding"] = { + "endpoint": "https://old-server.example:8443/t/former_home", + "server_instance_id": os.environ["OLD_INSTANCE"], + "remote_team_id": os.environ["PULL_TEAM_ID"], + "remote_team_name": "rebound", + "protocol_version": 1, + "cipher_profile": "none", + "connected_at": "2026-01-01T00:00:00Z", + "disconnected_at": None, + "binding_revision": 3, +} +with open(sys.argv[1], "w", encoding="utf-8") as handle: + json.dump(document, handle) + handle.write("\n") +PY + + run bash "$SCRIPTS/remote.sh" pull --endpoint "$ENDPOINT" \ + --team-id "$pull_team_id" rebound + [ "$status" -eq 0 ] || { echo "pull refused: $output"; false; } + + # The pull re-bound the team (different identity), and the binding it + # replaced is in the archive, not gone. + [ "$(_binding_field rebound server_instance_id)" != "$old_instance" ] + [ "$(_previous_count rebound)" = "1" ] + [ "$(_config_json_path rebound 'previous_bindings[0].server_instance_id')" = "$old_instance" ] + [ "$(_config_json_path rebound 'previous_bindings[0].endpoint')" = "https://old-server.example:8443/t/former_home" ] + [ -n "$(_config_json_path rebound 'previous_bindings[0].replaced_at')" ] +}