Skip to content

SSH launcher ignores a custom T3CODE_HOME and starts a second environment instead of reusing the service #17039

Description

@sliva0

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" and DEFAULT_RUNTIME_FILE point 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 as external.
  • Launch. serve --base-dir "$DEFAULT_SERVER_HOME" (tunnel.ts#L702). The flag wins over T3CODE_HOME (cli/app.ts#L200), so the server lands in ~/.t3 even though T3CODE_HOME=~/.config/t3 is in its environment (see Evidence). Exporting T3CODE_HOME on the host cannot fix this.
  • Pairing. auth pairing create --base-dir "$HOME/.t3" (tunnel.ts#L724-L733). Pairing also runs as a non-login sh -s (L939) while launch runs sh -l -s (L878), so the two do not necessarily see the same environment.
  • Launcher state and runtime cache are under ~/.t3 too (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:

  1. Resolve the home once on the remote: T3CODE_HOME if 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.
  2. 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

  1. 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.
  2. Make sure ~/.t3 does not exist on the host.
  3. In the desktop app on another machine, add an SSH route to that environment from Settings → Connections.
  4. The route fails with "a different machine". On the host, ~/.t3 now exists and a second t3 serve --base-dir ~/.t3 is 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/t3

Related issues

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

  1. juliusmarminge commented on Oct 8, 2026

    @juliusmarminge
    Member

    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

    • Every remote SSH script hardcodes $HOME/.t3 and never reads T3CODE_HOME or the installed service's home:
      • Discovery: DEFAULT_SERVER_HOME="$HOME/.t3" and DEFAULT_RUNTIME_FILE=…/userdata/server-runtime.json (tunnel.ts#L545-L547).
      • Launch: serve … --base-dir "$DEFAULT_SERVER_HOME" (L702).
      • Pairing: auth pairing create --base-dir "$HOME/.t3" (L723-L734).
      • Archive runtime cache (L449-L458) and launcher state for the stop/log scripts (L737, L760).
    • --base-dir takes precedence over T3CODE_HOME for serve, which falls back to ~/.t3 only when neither is set (cli/config.ts#L319-L327, os-jank.ts#L105-L111). So exporting T3CODE_HOME on the host can't redirect the launched server.
    • Both the runtime file and the environment ID live under <home>/userdata/ (config.ts#L164-L165). A server started on a fresh ~/.t3 therefore can't see the service's runtime file and comes up with its own environment ID.
    • Launch runs as sh -l -s (L878) and pairing as a non-login sh -s (L939), so a T3CODE_HOME set only in a login profile wouldn't reach pairing anyway.
    • The "a different machine" check runs only after ensureSshEnvironment has launched the server and issued a pairing token (web/connection/platform.ts#L190-L214). That error path doesn't call ssh.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.
    • The service's home can be read without asking the user. The systemd unit at ~/.config/systemd/user/t3code.service and the plist at ~/Library/LaunchAgents/com.t3tools.t3code.service.plist both carry T3CODE_HOME (bootService.ts#L108, L241-L247, L309-L314), and bootServiceBaseDirOf already parses it back out (L66). There's no service.env on main. 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#L200 link in the report is the t3 app command. The serve precedence is the cli/config.ts range 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 ~/.t3 with 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)

    1. Resolve the remote home once per connection and use it everywhere: discovery, serve --base-dir, pairing, the runtime cache and probably launcher state. The order would be T3CODE_HOME if set, then T3CODE_HOME from the installed unit or plist (the format bootServiceBaseDirOf reads), 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.
    2. When the environment ID doesn't match, call ssh.disconnect before returning the error. The stop script already skips external servers (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 ~/.t3 before 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

    I found no open PR that makes the SSH launcher honor a custom home.

  2. added
    bugSomething is broken or behaving incorrectly.
    via-triageFiled through npx t3 triage
    on Oct 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething is broken or behaving incorrectly.via-triageFiled through npx t3 triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions