# feat\(server\): link to another environment · PR \#16655 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/16655)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

State: open

Source repository: pingdotgg/t3code

Source branch: t3code/peer/links

Target branch: t3code/peer/launch-request-key

Head commit: 197930788c33a086de927374a30e02da4c6336c4

[Discussion and reviews](https://git.cafe/pingdotgg/t3code/pull/16655)

[Commits](https://git.cafe/pingdotgg/t3code/pull/16655/commits?format=markdown)

[Files changed](https://git.cafe/pingdotgg/t3code/pull/16655/diffs?format=markdown)

## Description

---

Part of cross-environment orchestration. An environment (A) can now link to another (B) and call B's T3 MCP tools as B's outside agent. It works headless and over Tailscale, LAN, Connect or loopback.

**Linking**
- `t3 environment link <url> --pairing-code … --access …` (or the `peerLinks.link` RPC) drives B's existing MCP OAuth flow server-side: register a loopback client that is never visited, approve with the pairing code, exchange with PKCE. B needs no new endpoint.
- B lists the session as `T3 Code · <A's label>` in Settings → Connections, where it can be revoked.
- A refuses to link to itself, and refuses a peer that can't honour `T3-Mode-Limit` (the new `mcpModeLimitHeader` capability) or, from #16695, keep a link's work within it.

**Storage**
- The token goes in A's server secret store under a random name the row records. The rest goes in `peer_environment_links` (migration 068).
- Links last as long as B's `mcp-client` session: 30 days, with no renewal yet.

**Calling**
- `PeerMcpClient` keeps one MCP session per peer address and re-initialises on a 404, for example after B restarts.
- It sends the calling agent's limits in `T3-Mode-Limit` on every request (#16653).
- It decodes results with the local tool's schema, and keeps B's refusal code so the agent sees why B refused.
- **The token only goes to an address that proves it is the linked peer:** its `/.well-known/t3/environment` must report the linked environment id. A reused LAN address or a moved hostname never receives it.

**Managing**
- `t3 environment list [--json]`, `t3 environment unlink <id>`, and the `peerLinks.list`/`link`/`unlink` RPCs. List needs `access:read`; link and unlink need `access:write`.
- Unlinking forgets the token on A. Revoking happens on B.

Agents start using links in the next PR.

**Verification**
- `peer/PeerLinks.test.ts` runs A's real `PeerLinks` and `PeerMcpClient`, each with its own database and secret store, against B's real descriptor, MCP OAuth and `/mcp` routes on a real socket. B uses the real `mcp-client` authenticator. The four tests:
  - A wrong pairing code creates no session. A good one links, B lists `T3 Code · Laptop`, and the link shows as reachable. A call carrying `approval-required/plan` arrives narrowed, and a call carrying `full-access` is capped at the link's `auto`. B's refusal code reaches the caller.
  - Once B's address answers as another environment, the call fails, B receives **no** bearer, and the link lists as unreachable.
  - Revoking on B → `capability_denied` with "link it again". Moving the clock 31 days → the link lists as expired, and no bearer is sent.
  - Linking itself, or a peer without the capability, is refused without spending the pairing code.
- Mutation-checked: each of these fails exactly its own test when removed:
  - the environment-id check before sending a token;
  - the self check;
  - the capability check;
  - the expiry check;
  - the `T3-Mode-Limit` header;
  - keeping B's refusal code.
- Also ran: `McpHttpServer`, `project/handlers`, `RpcAuthorization`, `initializeV2Database`, `cli/app`, `ws`, contracts `rpc` and `environment`. All passed. Server and contracts typecheck clean.
- CLI smoke test in a scratch base dir: `environment list` (text and `--json`), `link` to a dead address ("Nothing at … answered as a T3 Code environment"), and `unlink` of an unknown id.

**Review fixes** (bots plus two adversarial reviews)
- **Token and row stay paired.** Each link's token goes under a random name recorded in the row (`secret_name`, migration 068), written before the row points at it. Relinking removes the old token. Two links racing to one peer can no longer leave a read-only row holding a full-access token; a test forces that interleaving and fails with a shared name.
- **Errors say what happened.** Only the peer's own OAuth refusals become `pairing_rejected`. A dropped connection is `unreachable`, and an answer this version can't read, including a malformed redirect, is `incompatible` instead of a crash. Storage failures show a fixed message and log the cause.
- **No credentials in clear.** Plain `http://` is refused unless the address is this machine or a Tailscale address, for the primary and alternate addresses.
- **Slower routes.** The descriptor probe waits 10 s for non-loopback addresses (3 s on loopback), for a relayed or waking peer.
- `PeerLinks.get` reads one stored link without probing every peer, for callers that only need its label (#16719's follower and delegation).

Opus 5.5 via Claude Code.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- devin-review-badge-begin -->

---

<a href="https://app.devin.ai/review/pingdotgg/t3code/pull/16655" target="_blank"><picture><source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-devin-review-dark.svg?v=4"><img src="https://static.devin.ai/assets/gh-devin-review-light.svg?v=4" alt="Devin Review"></picture></a>
<!-- devin-review-badge-end -->




