Skip to content

[Bug]: systemd user unit from t3 service install sets no PATH, so providers in ~/.local/bin are invisible to the service #4913

Open

Description

@coreldh

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Not a duplicate, but same family as #4697 ("No installed editors found") and #4846 (Windows desktop resolver ignores User PATH). Those two plus this one all reduce to: the server resolves external commands against whatever PATH it inherited, and that PATH is wrong whenever it wasn't launched from an interactive login shell.

Area

apps/server

Steps to reproduce

On a headless Ubuntu box, with providers installed the ordinary way so their launchers land in ~/.local/bin:

  1. command -v claude codex → /home/caesar/.local/bin/claude, /home/caesar/.local/bin/codex
  2. npm i -g t3 (npm prefix is ~/.local/npm-global, no sudo needed)
  3. t3 service install
  4. systemctl --user start t3code.service
  5. Open the web UI and try to start a thread.

Expected behavior

The service resolves claude / codex exactly as t3 serve does when run by hand from a login shell.

Actual behavior

Providers are not found. The server itself is healthy — it listens, pairs, authenticates, and the web client works — but there are no usable providers, so no thread can run.

The cause is that the generated unit sets no PATH, so the service inherits the systemd user manager's minimal environment:

  • Service PATH (what t3 gets): /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin
  • Login shell PATH (what a human gets): /home/caesar/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin

~/.local/bin is prepended by Ubuntu's default ~/.profile, which only runs for login shells. A systemd user unit never sources it. So the one directory where user-scoped CLI tools normally live is exactly the one the service cannot see.

This also makes the failure confusing to diagnose: running t3 serve manually over SSH works if you have a login shell, so the provider list looks fine when you test by hand and empty when the service runs.

Impact

Major degradation or frequent failure

The headless Linux path is the "self-hosted server" use case, and on a fresh install it silently produces a server with zero providers.

Version or commit

t3@0.0.31

Environment

Ubuntu 26.04 LTS, kernel 7.0.0-28-generic, x86-64 · node v22.22.1 at /usr/bin/node · npm prefix /home/caesar/.local/npm-global · systemd user service with Linger=yes · no desktop session, headless, reached over Tailscale.

Logs or stack traces

# unit generated by `t3 service install` (verbatim) - note the absence of any PATH
[Unit]
Description=T3 Code server
StartLimitIntervalSec=300
StartLimitBurst=5

[Service]
Type=simple
WorkingDirectory=%h
Environment=T3CODE_HOME=/home/caesar/.t3
Environment=T3_BOOT_SERVICE_UNIT=t3code.service
ExecStart=/usr/bin/node /home/caesar/.local/npm-global/bin/t3 serve
Restart=always
RestartSec=5
StandardOutput=append:/home/caesar/.t3/userdata/logs/boot-service.log
StandardError=append:/home/caesar/.t3/userdata/logs/boot-service.log

[Install]
WantedBy=default.target

# what the running service actually has, before the workaround
$ tr '\0' '\n' < /proc/$(systemctl --user show t3code.service -p MainPID --value)/environ | grep ^PATH=
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin

# where the providers actually are
$ bash -lc 'command -v claude codex'
/home/caesar/.local/bin/claude
/home/caesar/.local/bin/codex

Workaround

A systemd drop-in at ~/.config/systemd/user/t3code.service.d/override.conf, which also survives t3 service update (editing the generated unit does not):

[Service]
Environment=PATH=/home/caesar/.local/bin:/home/caesar/.local/npm-global/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

After systemctl --user daemon-reload && systemctl --user restart t3code.service, providers resolve and threads run normally. Note that systemctl --user enable --now does not restart an already-active unit, so a plain enable --now after editing the drop-in silently keeps the old environment.

Suggested fix

Have t3 service install emit an explicit Environment=PATH= in the unit, captured from the installing user's login PATH (bash -lc 'echo $PATH') rather than from the environment the installer happens to run in. At minimum, prepend ~/.local/bin and the npm global bin directory.

Doing it at install time also fixes #4697-style reports on Linux, and is the same underlying defect as #4846 on Windows.

Related papercut (secondary, happy to split out)

t3 service install exposes no --host / --port, and the unit it writes binds to loopback. On a headless box that means the freshly installed service is unreachable from anywhere until you discover that T3CODE_HOST / T3CODE_PORT work as environment variables. I verified both do, via the same drop-in:

Environment=T3CODE_HOST=100.x.x.x
Environment=T3CODE_PORT=3773

Either exposing flags on service install or documenting those variables in the headless setup path would save the guesswork.

Activity

  1. martinfrancois commented on Aug 5, 2026

    @martinfrancois

    Before someone adds Environment=PATH= to the unit, two things worth checking.

    /proc/<pid>/environ is the environment the process started with, and does not change when the
    process edits process.env. The server edits it: fixPath() in apps/server/src/os-jank.ts
    runs unconditionally at startup, spawns $SHELL -ilc, and merges the login shell's PATH in.
    That code is in 0.0.31, not just main.

    On my host it recovers the directory, starting from exactly the service's exec-time environment:

    $ env -i HOME=/home/u USER=u SHELL=/bin/bash \
        PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin \
        /bin/bash -ilc 'echo $PATH'
    /home/u/.local/share/mise/shims:/home/u/.local/bin:/home/u/bin:/usr/local/bin:/usr/bin:/bin
    

    So ~/.local/bin is on the PATH the server resolves against, even though the unit never set
    it. If the providers were confirmed missing from the UI or from a spawn error in
    boot-service.log rather than from the PATH comparison, then the cause is probably elsewhere
    and this issue's title points at the wrong thing.

    Second, the obvious fix is a trap. A unit-level Environment=PATH= replaces the systemd user
    manager environment for that variable rather than adding to it, so anything arriving via
    ~/.config/environment.d/*.conf or systemctl --user import-environment is dropped. On Ubuntu
    that is how /snap/bin reaches user services, so a snap-installed tool that works today would
    go missing after the next t3 service update.

    Workaround for anyone who is genuinely affected, so nobody has to wait on this. A drop-in
    survives t3 service update rewriting the unit, unlike editing the unit itself:

    mkdir -p ~/.config/systemd/user/t3code.service.d
    cat > ~/.config/systemd/user/t3code.service.d/10-path.conf <<'EOF'
    [Service]
    Environment="PATH=%h/.local/bin:%h/bin:/usr/local/bin:/usr/bin:/bin"
    EOF
    
    systemctl --user daemon-reload
    systemctl --user restart t3code.service

    Check the drop-in applied. /proc/environ is the right tool for this one, since a drop-in sets
    the exec-time environment, which is exactly what that file shows. The PATH the server ends up
    resolving against may be longer, because fixPath() still merges the login shell's entries on
    top of it at startup:

    tr '\0' '\n' < /proc/$(systemctl --user show t3code.service -p MainPID --value)/environ \
      | grep ^PATH=

    Spell out the full value you want, including the system directories, for the
    replace-not-extend reason above. Keep the quotes: systemd expands %h before splitting the
    line on whitespace, so an unquoted value truncates for anyone whose home directory contains a
    space.

    AI Disclosure: Prepared with the assistance of Claude Code (Claude Opus 5). The commands and
    output above were run and observed on my own machine. Reviewed by me before posting.

  2. BJSchmidt commented on Sep 23, 2026

    @BJSchmidt

    Confirming a variant of this on t3@0.0.42, where fixPath() does not help: the T3 Connect relay client (cloudflared) in ~/.local/bin is invisible to the systemd service, so the managed tunnel never starts. t3 connect gives no hint.

    What happened

    • Switched from npx t3 serve to the self-contained install, then ran t3 service install.
    • t3 connect only said "T3 Code is already set up to run in the background on this machine." t3 connect status reported Environment link: provisioned and Relay client: available via PATH (~/.local/bin/cloudflared, 2026.5.2, the pinned version).
    • Remote clients could not connect. No cloudflared process was running.
    • The boot-service log only shows the failure after the 10-minute startup retry window:
    [22:13:36.993] WARN (#370): Failed to reconcile T3 Connect desired link on startup
      { message: 'Managed endpoint runtime could not be started.' }
    

    An earlier boot, with the same binary present, logged the underlying reason:

    WARN Failed to reconcile T3 Connect desired link on startup
      providerKind: 'cloudflare_tunnel',
      reason: 'The relay client is not installed.',
    

    The service was restarted several times, each less than 10 minutes apart, so for a while nothing was logged at all.

    Why fixPath() doesn't cover this

    The login shell does include ~/.local/bin (bash -ilc 'echo $PATH' confirms it), and fixPath() runs; its bash: cannot set terminal process group noise is in the log. However, the relay client doesn't read process.env.PATH directly. It resolves cloudflared through Config.string("PATH") (packages/shared/src/relayClient.ts:117-118). In the bundled effect-smol, ConfigProvider.fromEnv() copies process.env when the provider is constructed ({ ...process.env }). If the default provider was first used before fixPath() ran, it still holds the unhydrated PATH. Our reading of the code suggests that is what happens; we didn't verify it at runtime. Either way, the observed result is that providers may resolve through the hydrated process.env, while cloudflared resolution sees the systemd PATH.

    t3 connect checks resolution from the interactive shell, sees cloudflared on PATH, and never installs the managed copy under <T3CODE_HOME>/tools/cloudflared/…. So there is no fallback.

    Workaround (verified)

    Point the service at the binary explicitly with the existing override variable, via a drop-in:

    mkdir -p ~/.config/systemd/user/t3code.service.d
    printf '[Service]\nEnvironment=T3CODE_CLOUDFLARED_PATH=%s\n' "$HOME/.local/bin/cloudflared" \
      > ~/.config/systemd/user/t3code.service.d/cloudflared.conf
    systemctl --user daemon-reload && systemctl --user restart t3code.service

    Within a second of the restart, the service logged Relay client process started, T3 Connect desired link reconciled on startup and Relay client tunnel connection registered, and remote connection from another machine works. This sidesteps the Environment=PATH= replace-not-extend concern raised above, because it doesn't touch PATH.

    Suggestions

    • Resolve cloudflared against the hydrated PATH, for example by reading process.env.PATH after fixPath(), or by having fixPath() run before any ConfigProvider is built.
    • Or: when t3 connect / t3 service install finds cloudflared only via the user's PATH, persist it as T3CODE_CLOUDFLARED_PATH in the unit, or install the managed copy.
    • Surface tunnel failure in t3 connect status and t3 connect (today they report "provisioned" or "already set up" while the tunnel is down), and log the first reconcile failure right away rather than after 10 minutes.

    Environment

    • t3 0.0.42 (self-contained install, ~/.t3/runtime/versions/0.0.42/t3), systemd user service via t3 service install
    • Ubuntu 25.10 x64 (Proxmox kernel 6.17.2-2-pve), Node v26.8.2
    • cloudflared 2026.5.2 at ~/.local/bin/cloudflared
    • Service exec PATH: /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin

    Diagnosed and drafted via t3 triage with Claude Code (Claude Opus 5.5); reviewed by the reporter before posting.

  3. valshin commented on Sep 24, 2026

    @valshin

    Confirming on t3@0.0.42, Ubuntu 24.04 x64, headless, systemd user service (Linger=yes). Same symptoms as the comment above: cloudflared in ~/.local/bin, service exec PATH lacks it, environment.cloud.applyRelayConfig fails with Managed endpoint runtime could not be started., and the T3CODE_CLOUDFLARED_PATH drop-in fixed it immediately (tunnel registered within ~1s of restart).

    One extra detail: t3 connect status reports Version: 2026.5.2 for the PATH-resolved binary, but that binary is actually cloudflared 2026.8.2. The relay client's resolve returns the pinned CLOUDFLARED_VERSION for every source (managed, path, override) rather than the version of the executable it found, so the status output is misleading about the version too, not just about the tunnel being up.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions