Skip to content

[Bug]: macOS service pins Homebrew Node Cellar path, making Node upgrades unsafe #11054

Description

@szyprzy

Before submitting

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

Area

apps/server

Steps to reproduce

  1. On an Apple Silicon Mac, install Node with Homebrew and invoke Node through Homebrew's stable path:

    command -v node
    node -p 'process.execPath'

    On my machine this prints:

    /opt/homebrew/bin/node
    /opt/homebrew/Cellar/node/26.8.1/bin/node
    

    /opt/homebrew/bin/node is a symlink managed by Homebrew. process.execPath is the resolved, version-specific executable.

  2. Install or update the T3 background service from that environment. I reproduced the generated configuration after:

    npx t3@0.0.41-nightly.20260909.1439 service update
  3. Inspect the executable stored in the LaunchAgent:

    plutil -extract ProgramArguments.0 raw \
      "$HOME/Library/LaunchAgents/com.t3tools.t3code.service.plist"
  4. The plist contains the resolved Cellar path:

    /opt/homebrew/Cellar/node/26.8.1/bin/node
    
  5. A normal Homebrew Node upgrade can repoint /opt/homebrew/bin/node and eventually remove the old keg. At that point the LaunchAgent still references the old version-specific path.

I did not intentionally remove the currently running Node keg just to force an outage on this host. The deterministic repro here is the generated service definition retaining a path whose lifetime is tied to one Homebrew keg.

Expected behavior

A T3 background service installed with Homebrew-managed Node should remain startable across a routine Node upgrade, or the service lifecycle should otherwise make this dependency and the required maintenance explicit before the old executable disappears.

Actual behavior

The generated LaunchAgent stores the resolved Cellar path. The service works while that keg exists, but a later restart can fail once Homebrew removes it, even though the normal node command continues to work through /opt/homebrew/bin/node.

There is a related runtime concern: the stable service launcher also starts managed T3 runtimes through its own process.execPath. If the launcher remains alive while Homebrew replaces and removes its Node keg, a later managed-runtime handoff may also try to use the removed executable.

Impact

Major degradation or frequent failure

Version or commit

Observed in 0.0.41-nightly.20260909.1439; the relevant behavior is also present on main at d29c56a5c404cb0f58d3b2ac41762fa0d0ac28d4.

Environment

macOS 26.6.2 (Apple Silicon), Homebrew Node 26.8.1, npm 11.19.0, T3 background service managed by launchd

Logs or stack traces

$ ls -l /opt/homebrew/bin/node
/opt/homebrew/bin/node -> ../Cellar/node/26.8.1/bin/node

$ node -p 'process.execPath'
/opt/homebrew/Cellar/node/26.8.1/bin/node

$ plutil -extract ProgramArguments.0 raw \
    "$HOME/Library/LaunchAgents/com.t3tools.t3code.service.plist"
/opt/homebrew/Cellar/node/26.8.1/bin/node

Investigation notes

I inspected the installed package and current source to understand whether this was only a local plist change.

  • bootService.ts builds the service plan with nodePath: host.execPath, and the macOS renderer writes plan.nodePath into ProgramArguments[0].
  • serviceLauncher.ts starts a managed runtime with spawn(process.execPath, ...).
  • These two observations are consistent with the generated plist and process tree on my host.

Relevant source on the inspected commit:

  • const plan: BootServicePlan = {
    nodePath: host.execPath,
    launcherPath,
  • }
    if (this.#stopping) return;
    const paths = runtimePaths(this.#baseDir, version);
    const context: ServiceLauncherContext = {
    protocol: SERVICE_LAUNCHER_PROTOCOL,
    childVersion: version,
    ...(update === undefined ? {} : { update }),
    };
    const child = NodeChildProcess.spawn(process.execPath, [paths.entryPath, "serve"], {
    env: { ...process.env, [SERVICE_LAUNCHER_CONTEXT_ENV]: JSON.stringify(context) },
    stdio: ["inherit", "inherit", "inherit", "ipc"],
    });

I may be missing an intended assumption about how Node is provisioned for the background service. I am reporting the lifecycle mismatch rather than proposing a specific implementation. Preserving an upgrade-stable executable reference, resolving the current runtime at launch, or explicitly coordinating service maintenance with Node upgrades may have different trade-offs that are clearer to the maintainers.

Workaround

I currently keep a locally managed LaunchAgent configuration and check it after t3 service update. This avoids relying on the generated Cellar path, but I do not consider the local configuration a recommendation for the upstream design.

Activity

  1. juliusmarminge commented on Sep 10, 2026

    @juliusmarminge
    Member

    Triage

    Confirmed. This is a real boot-service bug, not a local plist misconfiguration.

    t3 service install / service update persist process.execPath as the service Node:

    • apps/server/src/cloud/bootService.ts sets nodePath: host.execPath and writes that into LaunchAgent ProgramArguments[0] (and systemd ExecStart).
    • HostProcessExecutablePath in packages/shared/src/hostProcess.ts is process.execPath.
    • Node realpaths that value, so Homebrew becomes /opt/homebrew/Cellar/node/<ver>/bin/node instead of /opt/homebrew/bin/node.
    • apps/server/src/serviceLauncher.ts then spawn(process.execPath, …) for managed runtimes, so a later handoff can also miss after the keg is removed.

    The renderer already refuses to depend on PATH (absolute Node is correct). The defect is pinning the keg realpath, whose lifetime is one Homebrew version. Tests even fixture macOS with /opt/homebrew/bin/node, which is the stable path install does not currently write.

    brew upgrade node can delete the old keg. The running process can survive on the old inode; the next launchd start (logout/reboot) or launcher handoff fails even though node on the prefix symlink still works. That matches the report. Forcing keg removal on a live host is not required — the generated plist is the repro.

    Not a duplicate of #11055

    #11055 is the LaunchAgent inheriting launchd’s 256 maxfiles and hitting EMFILE on userdata watchers. #11056 only adds SoftResourceLimits.NumberOfFiles. Neither changes nodePath. Keep these separate.

    No open PR fixes the Cellar pin. #8173 only put /opt/homebrew/bin on the agent PATH for provider CLIs.

    The same execPath pin is on Linux systemd. Distro Node is usually stable; linuxbrew/nvm can hit the same class of failure.

    Suggested fix

    Prefer a durable absolute Node when building the plan (Homebrew $prefix/bin/node when execPath is a Cellar keg; argv[0] when it is already a non-Cellar absolute). Do not switch to bare node. Also stop the launcher from spawning children only via process.execPath — launchd following a symlink does not change the running process’s execPath, so live handoff still breaks after a keg removal.

    homebrewOwnershipFromCommandPath in apps/server/src/provider/providerMaintenance.ts already knows Cellar layouts.

    Until then: point the LaunchAgent at the prefix node and re-check after t3 service update (that command rewrites the plist). After a Node upgrade, service update from a working Node also rewrites the pin. Full Disk Access may still key off the real binary and need a re-grant.

    Labels: bug. Removing needs-triage.

  2. added
    bugSomething is broken or behaving incorrectly.
    acceptedfeature request accepted
    via-triageFiled through npx t3 triage
    on Sep 10, 2026
  3. cestercian commented on Sep 10, 2026

    @cestercian
    Contributor

    Interested in a small fix here: prefer the stable Homebrew shim (dirname of process.execPath when under Cellar, or command -v node / /opt/homebrew/bin/node when that is what launched us) when writing LaunchAgent/systemd nodePath, instead of pinning process.execPath to a keg path.

    Happy to open a tightly scoped PR against bootService.ts / HostProcessExecutablePath if that direction matches what you want.

  4. juliusmarminge commented on Oct 2, 2026

    @juliusmarminge
    Member

    Thanks for taking the time to report this and provide the details. We revisited it during the orchestrator V2 cleanup.

    The boot service now launches a pinned standalone T3 executable, removing the reported dependency on a Homebrew Cellar Node executable that disappears on upgrade.

    I’m closing this based on the current source and the evidence in this thread.

    Source reviewed.

    Existing installations may need service reinstallation to replace a previously generated unit; this assessment concerns newly generated current services.

    If you still hit this on a current build, please reply with the app/server versions and the steps that reproduce it. We can reopen this if the original problem is still there.

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

    acceptedfeature request acceptedbugSomething 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