Repository navigation
SSH launcher ignores a custom T3CODE_HOME and starts a second environment instead of reusing the service #17039
Description
What happened
The host runs the background service with a custom home (T3CODE_HOME=~/.config/t3) and is already connected through T3 Connect. Adding an SSH route to that environment from Settings → Connections fails with:
That host reaches <label>, a different machine. Add it as its own environment instead.
It is the same machine. The SSH launcher did not find the service, started a second server on the host against a new ~/.t3 with its own environment ID, and left that server running after the client rejected it. Reproduces over LAN and Tailscale.
Diagnosis
The SSH launch scripts assume the remote server's home is $HOME/.t3. Nothing in them reads T3CODE_HOME or the installed service's home:
- Discovery.
DEFAULT_SERVER_HOME="$HOME/.t3"andDEFAULT_RUNTIME_FILEpoint at~/.t3/userdata/server-runtime.json(tunnel.ts#L545-L547). The service writes its runtime file under~/.config/t3/userdata/, so the launcher never sees it and never adopts the service asexternal. - Launch.
serve --base-dir "$DEFAULT_SERVER_HOME"(tunnel.ts#L702). The flag wins overT3CODE_HOME(cli/app.ts#L200), so the server lands in~/.t3even thoughT3CODE_HOME=~/.config/t3is in its environment (see Evidence). ExportingT3CODE_HOMEon the host cannot fix this. - Pairing.
auth pairing create --base-dir "$HOME/.t3"(tunnel.ts#L724-L733). Pairing also runs as a non-loginsh -s(L939) while launch runssh -l -s(L878), so the two do not necessarily see the same environment. - Launcher state and runtime cache are under
~/.t3too (L449-L458, L545, L737, L760). The host downloads a second copy of the runtime version the service already has under~/.config/t3/runtime/versions.
The new server generates a new environment ID, and the client rejects it (platform.ts#L212). That check runs after ensureSshEnvironment has launched the server and minted a pairing token, and nothing stops the server on a mismatch, so it keeps running. The message's advice is wrong here: following it adds a second environment on the same host.
The service's home is available without asking the user. The installed unit or plist records it, and bootServiceBaseDirOf (bootService.ts#L66) already reads it back for t3 update and t3 service status.
Possible directions:
- Resolve the home once on the remote:
T3CODE_HOMEif set, else the installed service's home, else~/.t3. Use it for discovery,serve, pairing, launcher state, and the runtime cache, and run pairing in the same login shell as launch. - When adding a route to an existing environment, adopt a running server instead of launching one, or stop the managed server that was just launched when the environment ID does not match. Reword the message so it does not say "a different machine" when the host may just have a different T3 home.
Steps to reproduce
- On a Linux host, install the background service with a non-default home, for example
T3CODE_HOME=~/.config/t3 t3 service install, and connect it through T3 Connect. - Make sure
~/.t3does not exist on the host. - In the desktop app on another machine, add an SSH route to that environment from Settings → Connections.
- The route fails with "a different machine". On the host,
~/.t3now exists and a secondt3 serve --base-dir ~/.t3is listening on 3774.
Version
0.0.46-nightly.20261007.2787 (service and desktop). Code references are main @ 83a82a4.
Environment
Host: CachyOS Linux x64, systemd user service, Node 26.8.2. Client: desktop app on a second Linux machine, over LAN and Tailscale.
Evidence
# Home directory shown as ~
# Installed service unit
Environment=T3CODE_HOME=~/.config/t3
ExecStart=~/.config/t3/runtime/versions/0.0.46-nightly.20261007.2787/t3 __service-launcher
# ~/.config/t3/userdata/server-runtime.json, the file the launcher should have found
{"version":1,"pid":<service pid>,"port":3773,"origin":"http://127.0.0.1:3773","serviceManaged":true}
# Server the SSH launcher started, still running after the client rejected it
# (ps and /proc/<pid>/environ)
PPID 1 ~/.t3/runtime/versions/0.0.46-nightly.20261007.2787/t3 serve --host 127.0.0.1 --port 3774 --base-dir ~/.t3
T3CODE_HOME=~/.config/t3Related issues
- feat(connection): support multiple routes per environment #7921 (closed, not merged) is the only prior work on this. It found the service's
T3CODE_HOMEthroughsystemctl --user show t3code.serviceand/proc/<pid>/environ, then paired against that home. It was closed for scope as part of a large multi-route PR. Multi-route support later landed in feat(clients): reach one environment over several routes #15467 without this piece, and adding an SSH route to a T3 Connect environment is the flow that hits this bug. The/procapproach is Linux-only and needs the service to be running; the unit file covers launchd and a stopped service. - SSH remote: client kills its own managed server on every reconnect (default-runtime adoption check) #15986, with open PRs fix(ssh): reconnecting no longer restarts the remote server #13521 and fix(ssh): stop killing the managed server on reconnect #13341: the launcher mistakes its own managed server for the service because both use
~/.t3/userdata/server-runtime.json. Different bug in the same discovery block; a fix here touches the same lines. - SSH reconnect can replace an external T3 service with a competing managed runtime #5749: the launcher replaces an external service with a competing runtime.
- Honor XDG Base Directory paths on Linux #10810: XDG base directories on Linux. A
T3CODE_HOMEunder$XDG_CONFIG_HOMEis that layout set up by hand.
None of these report a non-default home on the SSH host.
Fix applied or workaround
Symlink ~/.t3 to the custom home (ln -s ~/.config/t3 ~/.t3). With that in place, the launcher found the service and recorded it as external on port 3773.
If the launcher already started a server against a real ~/.t3, stop that server before replacing the directory. On graceful shutdown a server removes <home>/userdata/server-runtime.json without checking that it wrote it (server.ts#L748; #9045 proposes that check). Through the symlink, that file is the service's.
Filed by
Claude Code (Claude Opus 5.5) in T3 Code, building on a first pass by Codex (GPT-6) via t3 triage.
Activity
juliusmarminge commentedon Oct 8, 2026
Note
Grok responding on behalf of Julius.
Thanks for the detailed report. I checked it against
main@83a82a4, and the core of it holds up.Confirmed in code
$HOME/.t3and never readsT3CODE_HOMEor the installed service's home:DEFAULT_SERVER_HOME="$HOME/.t3"andDEFAULT_RUNTIME_FILE=…/userdata/server-runtime.json(tunnel.ts#L545-L547).serve … --base-dir "$DEFAULT_SERVER_HOME"(L702).auth pairing create --base-dir "$HOME/.t3"(L723-L734).--base-dirtakes precedence overT3CODE_HOMEforserve, which falls back to~/.t3only when neither is set (cli/config.ts#L319-L327, os-jank.ts#L105-L111). So exportingT3CODE_HOMEon the host can't redirect the launched server.<home>/userdata/(config.ts#L164-L165). A server started on a fresh~/.t3therefore can't see the service's runtime file and comes up with its own environment ID.sh -l -s(L878) and pairing as a non-loginsh -s(L939), so aT3CODE_HOMEset only in a login profile wouldn't reach pairing anyway.ensureSshEnvironmenthas launched the server and issued a pairing token (web/connection/platform.ts#L190-L214). That error path doesn't callssh.disconnect, so nothing tears down the tunnel or the managed server at that point. I didn't trace whether something stops it later. For example, the desktop tunnel manager's finalizer does run the stop script for tunnels still open when it shuts down.~/.config/systemd/user/t3code.serviceand the plist at~/Library/LaunchAgents/com.t3tools.t3code.service.plistboth carryT3CODE_HOME(bootService.ts#L108, L241-L247, L309-L314), andbootServiceBaseDirOfalready parses it back out (L66). There's noservice.envonmain. That file only exists in open fix(server): merge T3-home service.env into the background launcher #12633, under$T3CODE_HOME, so it wouldn't help the launcher find the home.One small correction: the
cli/app.ts#L200link in the report is thet3 appcommand. Theserveprecedence is thecli/config.tsrange above. It behaves the same way.Root cause. The SSH launcher assumes the remote home is
~/.t3. When the service uses a different home, discovery misses it, and a second managed server starts on~/.t3with a new environment ID. Adding a route to the existing environment then fails the ID check, and that server is left running.Possible fixes (untested)
serve --base-dir, pairing, the runtime cache and probably launcher state. The order would beT3CODE_HOMEif set, thenT3CODE_HOMEfrom the installed unit or plist (the formatbootServiceBaseDirOfreads), then$HOME/.t3. Pairing should get the same resolved value, either passed in or stored in the state dir, so it doesn't depend on which shell type sees the env.ssh.disconnectbefore returning the error. The stop script already skipsexternalservers (L736-L755). Separately, the wording could hint that the host may be using a different T3 home rather than being a different machine.Workaround. Use
ln -s ~/.config/t3 ~/.t3, as you found. Stop any server the launcher already started under a real~/.t3before you swap the directory. On shutdown, a server clears<home>/userdata/server-runtime.json(server.ts#L748), and through the symlink that file belongs to the service. Open #9045 targets that.Related
systemctl --user showplus/proc/<pid>/environ. That piece didn't land with feat(clients): reach one environment over several routes #15467.I found no open PR that makes the SSH launcher honor a custom home.