Skip to content

feat(connections): move to the successor server by reusing the agent's existing secret, falling back to a code - #6037

Merged
qingyun-wu merged 10 commits into
mainfrom
feat/connections-move-reuse-secret
Oct 30, 2026
Merged

qingyun-wu merged 10 commits into
mainfrom
feat/connections-move-reuse-secret

Conversation

@qingyun-wu

@qingyun-wu qingyun-wu commented Oct 30, 2026 •

Copy link
Copy Markdown
Collaborator

Priority

Level: medium
Reason: terminal-run cores still on the legacy DotSpace need a way to move without a new connect code whenever their existing secret still works.

Closes

Summary

The successor DotSpace (s.space) started from a byte-for-byte copy of the legacy (ag2.space) agent registry, relay bearers included. So an agent that neither server has re-coded since holds the same secret on both. connections.py move (and POST /api/v1/connections/move for the desktop) tries that secret first:

  1. successor_code(row) puts the row's own secret behind the successor's registered gateway host (same path). The successor comes from the existing dotspace LEGACY_SERVER / SUCCESSOR_SERVER mapping and the resolver. No new host literals are added, and the secret is never printed.
  2. try_reuse_move(instance) previews that code with whoami (fix(connections): a relay that lists the owner's whole fleet names the token's agent by whoami #6029). It continues only when the successor verifies the old row's agent localpart on the successor server. If the old row has not learned its agent yet, a preview of its own token supplies it. It then:
    • adds the successor row (skip_backlog, so the moved bridge settles the backlog stamped before it, as in feat(connections): a connection added with skip_backlog settles tasks stamped before it #6032);
    • promotes it through the existing promote, so the old bridge keeps running as a secondary until park-legacy;
    • re-points the agent MCP descriptor state/ag2-mcp.json: mint and MCP URLs from the successor's DotSpace entry, agent_id set to the moved agent, and env_file pointing at a 0600 channel env file holding the code, written atomically. The old descriptor is kept as ag2-mcp.json.bak-<legacy slug>;
    • mirrors each legacy worker-room binding to its successor twin (same room localpart) through the worker pool's bind_room. Core never names or imports the skill: the adapters pass the roster script path to room_bindings_from_script, which returns None when the script or its roster is absent. A twin that is already bound is left alone, and a refused binding is reported without undoing the move.
  3. Refusals write nothing and name a reason the caller can act on: no_successor, no_token, auth_rejected, unverified, agent_mismatch, successor_exists. The CLI then prints the code fallback (get a connect code at the successor's agent portal, then connections.py add). --dry-run checks the secret and writes nothing.
  4. The sspace-migration health hint now suggests move first. docs/connections.md ("Moving a terminal-run core to s.space") describes move first and the code path as the fallback.

Builds on #6029 (whoami settles identity), now merged; main is merged in, so the diff is the move change alone. #6031 and #6032 are already on main and are used as merged.

Checklist

Before / after evidence

New tests at the parent (#6029's head cb33e459, the source without this change):

tests/connections.test.py Move              Ran 15 tests  FAILED (errors=15)
tests/connections-cli.test.py               FAILED (failures=1, errors=1)   # move verb absent
tests/agent-api-connections.test.py         FAIL with nothing to move it answers 200 ok false no_successor — 404 {'error': 'not found'}
                                            FAIL GET move is 405 ... — 404
tests/health-check-sspace-migration.test.py FAILED (failures=1, errors=1)   # hint does not lead with move

At HEAD:

tests/connections.test.py                   Ran 324 tests  OK
tests/agent-api-connections.test.py         PASS — agent-api connections routes
tests/connections-cli.test.py               OK
tests/health-check-sspace-migration.test.py OK
tests/connections-room-routing.test.py, health-check-gateway-primary-connection, runtime-api-connections-view,
connections-no-cloud-trust, room-ops-room-connection: OK

Live read-only check on a host whose legacy agent was re-coded on s.space. As expected, the reuse is refused and nothing is written (the state files' checksums are the same before and after):

$ python3 scripts/connections.py --workspace <workspace> move --dry-run
connections: move refused: auth_rejected (s.space does not accept this agent's existing secret)
next: get a connect code at s.space's agent portal, then run python3 scripts/connections.py add '<code>'

Test plan

  • tests/connections.test.py Move:
    • the successor code swaps only the gateway host;
    • reuse accepted adds the row (not_before set, vault key AG2_REMOTE_TOKEN_S_SPACE) and makes it primary with its bridge running;
    • auth_rejected leaves the workspace and the vault byte-identical and starts no bridge;
    • another agent gives agent_mismatch, an unverified answer gives unverified, and a server without a successor gives no_successor with nothing probed;
    • a missing token gives no_token, and an existing successor row gives successor_exists;
    • a dry run writes nothing;
    • the MCP descriptor is re-pointed with a backup and a 0600 env file, and an off-legacy or absent descriptor is left alone;
    • room twins are mirrored (an already-bound twin is kept, a refused bind is reported), and the injected roster script works end to end;
    • the secret appears in no result.
  • The CLI (in process and through a subprocess) and the agent-api route (delegation, 405, 400s, a real no_successor round trip). No test touches the network: the gateway probes are the existing fetch and whoami stubs.

🤖 Generated with Claude Code

qingyun-wu and others added 7 commits October 29, 2026 19:53
…e token's agent by whoami

preview_connection verified an identity only when GET /v1/agents listed one
agent. AG2 Space relays list every agent of the token's owner, so a relay
token for one of several agents never verified: agent_mxid stayed null and
a later start refused with no_identity. The gateway's GET /v1/whoami names
the bearer's own agent from its registry; it now settles a multi-agent
listing, taken only for an agent of the resolver-registered server.

Rows imported from the environment (the legacy row included) never learned
an identity. agent-api now runs learn_missing_identities at start and every
minute, so GET /api/v1/connections names each row's agent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rner backs off and writes only on a verified preview

- A whoami id that a non-empty agent listing does not name leaves the row unverified;
  the bare-id fallback stays only for an empty listing.
- The whoami id is matched whole against a strict Matrix id (lowercase localpart grammar).
- One per-row helper (_learn_row_identity) holds the skip rules for both the launch path and
  the background learner; each passes its own token resolution.
- learn_identity runs only for a verified preview, so a pass that verifies nothing leaves
  connections.json untouched.
- run_identity_learner backs a row that did not learn off, doubling from the interval up to
  an hour, and logs a failed pass by exception type.
- Tests: whoami vs listing, strict id, disabled/parked rows, record untouched, backoff.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s existing secret, falling back to a code

The successor DotSpace's registry began as a byte-for-byte copy of the legacy one's, so an
agent neither server has re-coded since holds the same relay secret on both. `move` tries that
secret before anyone is asked for a connect code:

- successor_code(row): the row's own secret behind the successor's registered gateway host
  (same path), from the dotspace LEGACY_SERVER/SUCCESSOR_SERVER mapping; never printed.
- try_reuse_move(instance): previews that code (whoami included); proceeds only when the
  successor verifies the old row's agent localpart on the successor server. Then it adds the
  successor row (skip_backlog), promotes it, re-points the agent MCP descriptor
  (state/ag2-mcp.json: mint/MCP URLs, agent_id, a 0600 env_file holding the code, the old
  descriptor kept as .bak-<legacy slug>) and mirrors each legacy worker-room binding to its
  successor twin through an injected roster (room_bindings_from_script; core never names the
  skill). Refusals write nothing: no_successor, no_token, auth_rejected, unverified,
  agent_mismatch, successor_exists.
- CLI `connections.py move [INSTANCE] --repo --logs-dir [--dry-run]`, printing the code
  fallback on a refusal; `POST /api/v1/connections/move` for the desktop.
- The sspace-migration health hint suggests `move` first; docs/connections.md describes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@sonichi sonichi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes at 12ae0cb (agent-assisted, Blue for Chi's seat). The secret handling is sound:

  • The successor host comes only from the resolver (legacy → configured successor → registered gateway_host), with no new literals.
  • The secret goes out only in the Authorization header over the pinned probe, and never into output, URLs or errors.
  • The move requires the successor to verify the same localpart on the matching server.
  • The route uses the same bearer_request_allowed gate as the other routes.
  • Writes are atomic and 0600.

The four changed test files pass locally (324, 21, PASS and 7). What I'd fix:

  1. The re-pointed descriptor keeps the old relay, so the MCP proxy refuses the bearer. repoint_mcp_descriptor updates env_file, env_key, agent_id, mcp_url and mint_url, but not relay (or bearer_digest). The proxy's fallbackAllowed refuses the bearer from the descriptor's own key when the token's URL half (now the s.space relay) doesn't match d.relay (still ag2.space). So the move reports mcp: repointed and every MCP call then has no bearer. The test fixture omits relay and bearer_digest, so the suite can't see it. Update relay to the successor's (and recompute or drop bearer_digest), and add both fields to the fixture.
  2. Which descriptor is this? This writes <workspace>/state/ag2-mcp.json, but on desktop installs the descriptor is $APP_SUPPORT/channels/ag2-mcp.json, written by the host (agent_mcp.rs). Nothing in this repo writes the state/ path. Please name the component that reads state/ag2-mcp.json for terminal-run cores. If the host owns the file, a second writer here will be overwritten, or will quietly report mcp: absent on desktop.
  3. A promote failure leaves a half-move. After add_connection, a refused promote keeps the s-space row. A retry hits successor_exists, and the CLI says to run promote by hand. But a manual promote never re-points the descriptor or mirrors room bindings, so they stay on ag2.space. Let move resume when the existing successor row is the same agent.
  4. An OSError from the descriptor or env write after promote isn't caught: you get a traceback (or a 500), the row is promoted, and rooms aren't mirrored.
  5. Low:
    • _env_file_with writes KEY=https://…|secret unquoted, so anything that shell-sources it runs the part after | as a command. Quote it.
    • Compare owner from the preview too.
    • The refusal when ag2.space can no longer verify the old agent says agent_mismatch; unverified is the accurate reason.
    • pool_room_bindings is copied identically in scripts/connections.py and src/agent-api.py; put it in one place.

This carries #6029's diff, so it should merge after #6029.

@github-actions

github-actions Bot commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 94%.

Diff Coverage

Diff: origin/main...HEAD, staged and unstaged changes

  • scripts/connections.py (100%)
  • src/agent-api.py (100%)
  • src/connections.py (100%)

Summary

  • Total: 229 lines
  • Missing: 0 lines
  • Coverage: 100%

…nreachable no_token branch

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@qingyun-wu
qingyun-wu requested a review from sonichi October 30, 2026 05:11
@sonichi

sonichi commented Oct 30, 2026

Copy link
Copy Markdown
Owner

Re-checked at bf8bba1 (agent-assisted, Blue for Chi's seat). The new commit adds tests for the refusal and fallback paths and drops the unreachable no_token branch, but it doesn't touch the items in my review, so the request stands. Still open:

  1. repoint_mcp_descriptor doesn't update relay/bearer_digest, so the proxy refuses the moved bearer.
  2. Which component reads state/ag2-mcp.json? (On desktop the descriptor is channels/ag2-mcp.json, written by the host.)
  3. A promote failure leaves a half-move that a retry can't resume.
  4. An uncaught OSError after promote.

Coverage is also at 93% against the 95% bar.

…lf-move, and reports instead of raising

Review follow-up:
- The re-pointed MCP descriptor's `relay` moves to the successor (the MCP proxy refuses a bearer
  whose relay differs). `bearer_digest` is kept when it hashes this secret, else dropped.
- The descriptor is the one the core's Claude Code MCP config names (AG2_MCP_DESCRIPTOR) inside
  the workspace, else state/ag2-mcp.json; the desktop host's own descriptor is never touched.
- A successor row for the same agent (a move whose promote failed) is resumed: promoted, the
  descriptor re-pointed, rooms mirrored. Another agent's row is still successor_exists.
- A failed descriptor or env-file write is reported as mcp: write_failed; rooms still mirror.
- The env file value is single-quoted, so sourcing it never runs what follows the `|`.
- The owner is compared too; an old gateway that no longer confirms the agent is `unverified`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

@cla-assistant check

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Addressed in 6826536, on top of a merge of main (#6029 has landed, so the diff is now the move change alone):

  1. relay: the re-pointed descriptor's relay now moves to the successor gateway, the URL half of the code. bearer_digest stays when it is sha256 of this secret, which it is, because the secret did not change. A digest of some other secret is dropped. The fixture now carries both fields, and the test asserts both.
  2. Which descriptor: for a terminal-run core, the reader is the desktop engine's ag2-mcp-proxy.mjs, started from the core's own Claude Code MCP config: an mcpServers.<name>.env.AG2_MCP_DESCRIPTOR entry in <CLAUDE_CONFIG_DIR>/.claude.json. On such a core that entry names <workspace>/state/ag2-mcp.json. mcp_descriptor_path now follows that entry, and only for a path inside the workspace; otherwise it falls back to state/ag2-mcp.json. The host-owned channels/ag2-mcp.json on desktop installs is outside the workspace and is never chosen, so on desktop this reports absent and the host stays its only writer.
  3. Half-move: a successor row for the same agent is resumed. move promotes it (idempotent), re-points the descriptor and mirrors the rooms. The CLI's message after a refused promote is now "run move again". Only another agent's row gives successor_exists. Test: test_a_half_move_whose_promote_failed_is_resumed.
  4. OSError: a failed env-file or descriptor write returns mcp: write_failed, leaves the descriptor unchanged, and still mirrors the rooms.
  5. Low:
    • The env value is single-quoted. Tests read it back through token_from_env_file and through sh sourcing the file, which prints the code and runs nothing after the |.
    • The owner is compared by localpart when both sides name one (test_another_owner_on_the_successor_is_refused).
    • An old gateway that no longer confirms the agent now gives unverified.
    • pool_room_bindings: the shared logic already lives in one place, connections.room_bindings_from_script. Each adapter keeps only its call with the roster script path, because docs/architecture-boundaries.md ("Optional adapter capabilities") keeps skill discovery at the adapter edge: core must not name or locate a skill. Happy to change this if you'd rather have it elsewhere.

tests/connections.test.py: 331 OK. agent-api-connections, connections-cli, health-check-sspace-migration, connections-room-routing and runtime-api-connections-view: OK. The move section of src/connections.py is fully covered by the Move tests.

@sonichi sonichi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving at 6826536 (agent-assisted, Blue for Chi's seat). Every item from my review is addressed:

  1. Relay: relay is re-pointed to the code's gateway (and counts toward "on legacy"). bearer_digest is kept only when it equals sha256(secret), matching the proxy's "secret half" digest, and dropped otherwise.
  2. Descriptor: mcp_descriptor_path takes the AG2_MCP_DESCRIPTOR the core's Claude Code config names inside the workspace, else state/ag2-mcp.json. The desktop host's descriptor outside the workspace is never touched, which settles the second-writer question.
  3. Resume: a successor row for the same agent is resumed (promote, re-point, mirror), and another agent's row is still successor_exists.
  4. Write failures: they now return mcp: write_failed instead of raising, and rooms still mirror.
  5. Smaller items: the env value is single-quoted, the owner localpart is compared, and unverified is used when the old gateway no longer names the agent.

The changed test files (connections, connections-cli, health-check migration) pass locally. CI was still running; coverage needs to clear 95% before merge.

@qingyun-wu
qingyun-wu merged commit be4cd77 into main Oct 30, 2026
38 checks passed
@qingyun-wu
qingyun-wu deleted the feat/connections-move-reuse-secret branch October 30, 2026 05:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants