Linked environments: can't link machines on a local network because pairing only produces http:// and + Link rejects http#26840

opened5d agobySsfourdrinier

Bug

Linking environments between machines on the same local network is impossible.

Steps

  1. On a headless machine on the LAN, run npx t3 pair to generate a pairing code.
  2. The only output is a plain http://192.168.x.x:3773/pair#token=... URL. No https:// address is generated.
  3. On another machine, go to Settings > Environments > Linked environments > + Link, pick that machine and paste the pairing code.

Result

The link is refused:

1PeerLinkError: This environment could not reach <machine>: http://192.168.x.x:3773/ is plain http, which would send the link's credentials unencrypted. Use its https address, or its Tailscale address.

So the pairing flow only produces http on a local network, and + Link only accepts https. Direct machine-to-machine linking on a trusted LAN has no working path unless you route through T3 Connect's relay or set up Tailscale.

Expected

Linking between machines on a local network should work. Options: accept private-range addresses (RFC1918, loopback) with an explicit opt-in or confirm step, or have t3 pair / t3 serve provide an https LAN address.

Environment

  • 0.1.4-nightly.20261029.3853 (macOS desktop app) linking to headless Linux machines running npx t3
  • Also seen on npx t3 pair: Database migration history diverges from this build (70:PeerLinks, 71:PeerLinkFence, 72:WaitingLinkRequestIndex unknown to this build).
Synced from GitHub

Also reproduced on 0.1.4-nightly.20261028.3841. The local T3 server is on macOS arm64; the three remote T3 servers run the same version on Linux, including one under WSL2.

What happened

My app already has four working regular environment connections over my home LAN. I want agents on this Mac to control threads on the remote machines, with access only in that direction. Trying to create Linked environments for three of those existing connections fails for every machine because the selected LAN address uses HTTP.

Why can regular environments connect over LAN, while agent links to those same already-paired environments cannot? Is that distinction intentional, and what is the supported LAN-only setup? Could the linking flow reuse an existing authenticated connection, provide the required secure route, or offer an explicit trusted-LAN option?

Reproduction and evidence

  1. Connect the app to remote T3 environments using their private LAN addresses on port 3773.
  2. Enable Linked environments on the participating machines.
  3. Request a link from the Mac to an existing remote environment and supply a fresh pairing code generated using that remote server's matching T3 build.
  4. Linking fails with the following error (address and machine name anonymized):
text
1This environment could not reach <machine>: http://192.168.1.20:3773/ is plain http, which would send the link's credentials unencrypted. Use its https address, or its Tailscale address.

Diagnosis

The release tag resolves to 9e09926d97824179d97311d1a810c6b5bb60d2d1. Its peerOrigin validation deliberately rejects non-loopback HTTP unless the hostname is recognized as Tailscale. This is a transport-policy rejection before pairing, rather than evidence that the generated pairing codes are invalid.

One attempt also selected a numeric Tailscale IPv4 address and rejected it with the same wording, matching #26450. isTailnetHost recognizes .ts.net names and Tailscale IPv6, not the shared IPv4 range. Tailscale was off on the initiating Mac, so a working Tailscale route is not being claimed here.

The confusing part is that the app offers existing, working LAN environments for linking, then requires additional networking infrastructure without an actionable setup path. I understand that credential encryption may be intentional; the question is how the supported LAN workflow is meant to work and why the existing connection cannot be used.

No HTTPS proxy, SSH tunnel, or successful peer-link workaround has been applied.

Investigated and written with GPT-6-astra through Codex in T3 Code.

Synced from GitHub

Also reproduced on stable 0.1.5 at both ends (Windows desktop app linking to Linux t3 serve running as a systemd user service), so this isn't limited to nightlies. The network is different too: the machines are on a WireGuard overlay (10.66.66.0/24), not a LAN and not Tailscale.

What happened

Linked environments was turned on at both ends. I made a pairing code on the Linux machine with t3 auth pairing create --ttl 15m, then started the link from a thread's link card on the Windows machine with Supervised access, picking the already-paired machine. It failed:

1PeerLinkError: This environment could not reach kvm2: http://10.66.66.7:3773/ is plain http, which would send the link's credentials unencrypted. Use its https address, or its Tailscale address.

The desktop app and my phone have been paired to that same http://10.66.66.7:3773 address for weeks.

Diagnosis

Same cause as the original report. peerOrigin in apps/server/src/peer/PeerLinks.ts accepts plain http only for loopback or isTailnetHost (.ts.net names and fd7a:115c:a1e0::/48). It's unchanged between v0.1.5 (6d450a4) and main today (dd14848). The server trace shows PeerLinks.answeringAs failing after 1.3 ms with one candidate address, so the check runs before any request goes out and the pairing code never left the machine.

Neither fix the message suggests is available here. Tailscale isn't in use, and as far as I can tell 0.1.5 has no TLS listener of its own (the only built-in https is --tailscale-serve). An https address therefore means a reverse proxy in front of the server, and with a self-signed or private-CA certificate the linking server rejects it. #27128 would at least report that as a certificate error instead of "Nothing answered".

What I'm asking for

I'm not asking for private ranges to be trusted by default. Our overlay is hub-and-spoke, and the hub decrypts peer-to-peer traffic, so plain http over a 10.x address is not end-to-end encrypted. The current refusal is right about that. What's missing is a supported way to link without Tailscale. Any of these would work:

  1. An explicit per-link opt-in to plain http, shown with the warning, for users who accept their network's risk.
  2. A configured trusted network (CIDR or interface) on the linking server. This would be the general form of what #26475 does for Tailscale by checking tailscale status.
  3. TLS on the server itself, with the certificate fingerprint pinned at pairing, so a self-signed certificate works and nothing on the path (our hub included) can read the pairing code or the link token.

For our setup, option 3 is the only one that takes the hub out of the trust path. Options 1 and 2 mean accepting that risk knowingly, which may be fine for other people's networks.

Related: #26450 and #26475 (numeric Tailscale IPv4), #26431 (Network access serves plain http), #27128 (untrusted peer certificate error).

Investigated and drafted with Claude Opus 5.5 (claude-opus-5-5) via t3 triage in T3 Code.

Synced from GitHub

The same refusal applies over NetBird. #27678 was closed as a duplicate of this issue with the NetBird case attached, so I'm picking that thread up here and asking whether one direction would be accepted before I open a PR.

Setup

Linux machines on a self-hosted NetBird network (daemon v0.71.4, kernel WireGuard). Each gets an address in 100.86.0.0/16 and a name under netbird.selfhosted. peerOrigin on main accepts plain http only for loopback and isTailnetHost, and since #17158 that excludes numeric 100.64.0.0/10 addresses. So a link to http://100.86.x.y:3773 gets the usual "is plain http, which would send the link's credentials unencrypted. Use its https address, or its Tailscale address." NetBird has no .ts.net equivalent, so no address passes.

Proposal

I'd like to do for NetBird exactly what #26475 does for Tailscale, and nothing broader.

peerOrigin would also accept a plain-http host in 100.64.0.0/10 when netbird status --json on the linking machine shows the daemon connected to its management server and lists that address as its own netbirdIp or as the netbirdIp of a peer whose status is Connected. Every other shared-range address keeps failing, so the WARP and CGNAT reasoning behind #17158 still holds. The CLI would only run for http URLs in that range, and a missing or failing netbird binary means refusal. resolve would re-check the origin before sending a stored link's token, the same way #26475 does.

Relayed NetBird connections still carry WireGuard-encrypted packets end to end, so I wouldn't treat connectionType: "Relayed" differently from "P2P".

This would build on #26475's shape, and I'd wait for that PR to land so the two checks share one gate in peerOrigin instead of growing in parallel.

Out of scope

  • Private ranges in general (10/8, 192.168/16). psicilia5179's hub-and-spoke case above shows why trusting those by default is wrong.
  • A user-configured trusted-CIDR setting. Option 2 in #27678 would also cover NetBird, but it's a broader policy choice than this issue needs.
  • NetBird DNS names. MagicDNS names pass today because .ts.net is fixed. NetBird domains are chosen per deployment, so a name would have to be matched against the fqdn values in netbird status too. I left names out to keep the first version small. Linking by address would be enough.

Questions

  1. Is "verify against the local NetBird daemon, as #26475 does for Tailscale" an approved direction for this issue?
  2. NetBird has a netstack mode with no OS interface, where traffic to 100.x would bypass the tunnel. That's the same concern #26475 handles with TUN: true. netbird status --json reports usesKernelInterface, but false there also covers the normal wireguard-go TUN, so it isn't the right signal. I haven't found a status field that identifies netstack. Is accepting only when the daemon reports a connection good enough here, or should this wait for a reliable signal?

Investigated and drafted with Claude Opus 5.5 (claude-opus-5-5) in T3 Code.

Synced from GitHub

IMO - (I think I'm the only non bot here!)

When on my local network it's fine to connect between machines with http, but t3code in this instance doesn't support it. I should have have to use tailscale or something else. We should be able to connect directly between the machines on a local network without having to have external elements like t3 connect or other mechanisms.

Especially i have multiple routes to my servers, http and ssh.

or maybe t3 code should support it's own certificate and also listen on a https interface and advertise it when links are generated. It would solve this.

I'm still not able to link between my Mac and my Linux machines for "Linked Environments"

Synced from GitHub

Example, on one linux machine: npx t3 pair Pairing URL: http://192.168.68.64:3773/pair#token=M4XXXXXXXX App link: t3code://pair?pairingUrl=http%3A%2F%2F192.168.68.64%3A3773%2Fpair%23token%3DM4XXXXXXXX Token: M4XXXXXXXX Expires: 2026-10-30T18:29:52.442Z

Other machine: npx t3 environment link http://192.168.68.64:3773 --pairing-code 'M4XXXXXXXX' --access full-access

Result: [11:27:10.898] ERROR (#2): PeerLinkError: t3code://pair?pairingUrl=http%3A%2F%2F192.168.68.64%3A3773%2Fpair%23token%3DM4XXXXXXXX is not an http or https address.

Synced from GitHub

Also reproduced on 0.1.6-nightly.20261030.3940 at both ends (two macOS desktop apps on one T3 Connect setup; bug happened on this build, per the trace and each server's descriptor). The network is a LAN plus a ZeroTier overlay, so the refused address can already be encrypted, but peerOrigin can't tell.

Picking the machine in Settings → Environments → Linked environments → Link failed in both directions:

1MacBook → mini: could not reach <mini>: http://<zerotier-ip>:3773/ is plain http [...] http://<lan-ip-1>:3773/ is plain http [...] http://<lan-ip-2>:3773/ is plain http [...]
2mini → MacBook: could not reach <MacBook> at http://127.0.0.1:3773/.

The addresses are chosen by the app when a machine is picked; the user never entered them, and the dialog offers no way to edit them while the list is non-empty. The only way out is to not pick the machine and type an address instead.

Neither attempt included a T3 Connect address, although linkableMachines (packages/client-runtime/src/linkRequest.ts) should append one from relay discovery. The MacBook's tunnel was up: over its t3coderelay.com address, /.well-known/t3/environment returned the right environment, /mcp returned 401, and the OAuth metadata returned 200. Why discovery contributed nothing is not established; an empty discovery state on the client is a guess.

Two gaps follow:

  1. The picker can omit the one route peerOrigin would accept, and the error doesn't mention T3 Connect.
  2. For the mini → MacBook attempt, the only candidate was the target's loopback address, which the linking machine resolves to itself (same family as the comment on #26450).

Manually entering the https T3 Connect address is the expected workaround; not yet tested end to end here.

Investigated and drafted with Claude Opus 5.5 (claude-opus-5-5) in Claude Code via t3 triage.

Synced from GitHub

For the NetBird case here and in #27678, I opened an Ideas discussion that proposes NetBird as a first-class remote path alongside Tailscale, starting with siew24's daemon-verified link check: https://github.com/pingdotgg/t3code/discussions/28615. Linking it here so the link fix and the wider scope stay connected.

Synced from GitHub

If we can't connect locally on our LAN, the delegate feature is not particularly useful to me. I don't want to run traefik or similar just to get https and make this work. Hopefully this was just something the codeowners missed because it isn't their specific use-case (ie I suspect they all use tailscale or t3connect) and not a deliberate decision.

Synced from GitHub

Same refusal, Linux to Linux, both ends on the current line.

Setup

  • Linking from a Linux desktop app to a headless Linux box on the same LAN: t3 serve on 0.0.0.0:3773, no TLS, no Tailscale, Linked environments on at the target.
  • Settings → Environments → Linked environments → Link an environment → machine workspace-hermes (http://192.168.1.123:3773/), permission Full access → Link.

Result

1Could not link the environment
2PeerLinkError: This environment could not reach workspace-hermes: http://192.168.1.123:3773/ is plain http, which would send the link's credentials...

The toast truncates there; it is the peerOrigin refusal described above.

Notes

  • Reproduced 2026-11-02. I did not capture the exact client build string.
  • The dialog lists the machine and its http:// address with no warning, and only refuses after you press Link, so a LAN-only pair always fills the form and then hits the dead end. Filtering or annotating non-linkable addresses in the picker would save that trip.
  • t3 pair on the target only prints http://192.168.1.123:3773/pair#token=..., so there is no https address to substitute, same as the original report.
  • The two machines reach each other fine over that address; the block is the scheme check, not reachability.
  • T3 Connect relay and Tailscale are the only working paths I have.

Happy to test a private-CIDR opt-in build on Linux if one lands.

Synced from GitHub

I opened #29709 to fix this.

Links now accept plain http to a private-network address and still refuse it to a public one. "Private network" is the isPrivateNetworkHost classification clients use for LAN routes, which covers the addresses t3 pair and Network access print. Two rules stay in place:

  • A machine picked in Settings still links through an https, loopback or tailnet address whenever one answers.
  • A link made over one of those never stores or falls back to a plain-http address.

That is the rule paired client tokens already follow (tokenAllowsPlainHttp).

As far as I can tell, the refusal was not a deliberate policy:

  • When the app is connected to the other machine, the Settings flow mints the link's pairing code over that connection. On a LAN, that is the same plain-http route, and the session minting it can create codes for any access.
  • #16655 described links as working over LAN.
  • t3 environment link --help lists a LAN address.

The PR has the details, a failing-first test and a before/after runtime transcript. It also notes how it interacts with #26475.

Investigated and written with Claude Opus 5.5 through Claude Code in T3 Code.