Skip to content

[Bug]: Packaged builds set server cwd to $HOME; ClaudeProvider auxiliary spawns leak it past project bindings #2867

Description

@renatogcarvalho

Summary

In packaged desktop builds, the Node server child process is deliberately spawned with cwd = $HOME. Two functions in ClaudeProvider.ts then spawn the claude binary without specifying any cwd of their own, so they inherit $HOME — regardless of whether the user has a Project bound to the current thread.

For users on stock claude, this is mostly invisible. For any third-party wrapper that replaces or shims the claude binary and treats its cwd as the project boundary (containerized agent runtimes, mount-scoped sandboxes, governance shims), every capability probe and ad-hoc CLI call comes in with cwd = $HOME and either exposes the user's entire home directory to the agent or fails outright. The session path itself plumbs the project's workspaceRoot correctly, but the probe-driven provider snapshot runs first, fails, and marks the provider Unavailable — so the user never reaches a working session even with the binding set.

Where it happens

1. Server cwd is set to $HOME in packaged builds.

apps/desktop/src/app/DesktopEnvironment.ts:

backendCwd: input.isPackaged ? homeDirectory : appRoot,

backendCwd flows through DesktopBackendConfiguration.cwd into DesktopBackendManager's ChildProcess.make(..., { cwd }). So the server's process.cwd() is $HOME in any installed build.

2. Claude binary spawns inherit it.

apps/server/src/provider/Layers/ClaudeProvider.ts:

  • probeClaudeCapabilities calls the SDK's claudeQuery without any cwd option — the SDK then spawns claude with the parent process's cwd.
  • runClaudeCommand uses ChildProcess.make(claudeSettings.binaryPath, args, { env, shell }) with no cwd option — same inheritance.

Both inherit $HOME from the server process.

3. Session path is fine, but it's gated behind the probe.

apps/server/src/orchestration/Layers/ProviderCommandReactor.ts correctly resolves effectiveCwd from the thread's bound project and passes it to providerService.startSession({ cwd }). That path is well-formed. The problem is the snapshot-refresh probe runs on a 5-minute interval (and on provider events) and uses the broken path, which is what surfaces the provider as "Unavailable" in the UI before a session even starts.

Why it matters

  • Wrappers reasonably assume cwd ≈ project boundary. The Anthropic CLI behaves this way and any wrapper treating cwd as scope (mount roots, allowed-path lists, audit context) inherits that assumption. T3 Code silently breaks it for everyone running a packaged build.
  • The binding UI implies the project's path is what reaches the agent. A user who binds a thread to ~/code/myproject has no way to know that periodic probes are still firing from $HOME. Cwd leakage past an explicit binding is surprising and hard to debug from outside.
  • On macOS, $HOME happens to contain dot dirs the kernel blocks from being mount-overlaid (.Trash etc.), so for container-based wrappers the symptom is a cryptic runc EPERM rather than "we exposed your home folder." Either outcome is bad.

Related but distinct: #316 (closed) tightened client-supplied cwd on WebSocket RPCs. The surface here is server-side: the server's own process.cwd() and the unscoped spawns inside ClaudeProvider.

Repro

  1. Install a packaged desktop build to /Applications.
  2. Add a Project pointing at any real repo (e.g. ~/code/myproject). Start a new thread inside it — confirm the thread header shows the Project name.
  3. Replace claude on PATH with a wrapper that logs its cwd and exits, or use any wrapper that scopes to cwd.
  4. Observe: every capability probe (and any provider snapshot refresh) invokes the wrapper with cwd = $HOME, not the bound Project's workspaceRoot.

Suggested fix

Two parts:

A. Decouple the file-picker default-open path from the server's process.cwd().

The packaged-build backendCwd = homeDirectory looks like it exists so the "Add Project" picker opens at $HOME. That UX default should be sourced from a config field (it already has a UI surface — "Add project starts in") and read by the picker directly, not by setting the server's process cwd. Set backendCwd to something neutral (e.g. the app's user-data directory, or appRoot).

B. Pass an explicit cwd to every claude spawn in ClaudeProvider.ts.

Both probeClaudeCapabilities (via the SDK's cwd option) and runClaudeCommand (via ChildProcess.make's cwd option) should pass an explicit, scoped path — at minimum, the user-data directory; ideally, the bound project's workspaceRoot when one is available in context. Falling through to the parent process's cwd is the bug.

Doing only (B) is sufficient to unbreak third-party wrappers. Doing (A) as well removes the underlying source of surprise and is the more honest fix.

Environment

  • Version: Alpha (packaged desktop build)
  • OS: macOS (the .Trash interaction makes this most visible here, but the cwd leak itself is OS-independent)
  • Build: packaged desktop (isPackaged === true)

Activity

  1. rafazaya commented on Aug 30, 2026

    @rafazaya

    This can be broadened beyond ClaudeProvider.ts and cwd: the same class of
    auxiliary spawns also leaks the backend's environment, and each driver currently
    handles these differently. There are details and a decision point for maintainers
    below, but in short, I believe both leaks (env and cwd) across all providers can
    be handled with one fairly simple pattern.

    On Linux AppImage builds, provider sessions inherit ELECTRON_RUN_AS_NODE=1,
    APPDIR, LD_LIBRARY_PATH, and /tmp/.mount_* PATH entries — details in
    #1162 (comment).
    ClaudeProvider.ts falls back to environment ?? process.env on its probe path,
    and mergeProviderInstanceEnvironment (used by all five drivers) likewise defaults
    to raw process.env.

    On the cwd side the drivers have drifted apart:

    • ClaudeProvider.ts passes no cwd on auxiliary spawns (inherits the server's —
      this issue).
    • CursorProvider.ts explicitly passes cwd: process.cwd() — the same $HOME in
      a packaged build, just spelled out.
    • GrokDriver.ts and OpenCodeDriver.ts pass ServerConfig.cwd (the server's
      startup cwd).
    • CodexProvider.ts threads a cwd parameter through its status-check plumbing.

    Env construction diverges the same way (environment ?? process.env vs
    ...(environment ? { env } : { extendEnv: true }) vs the driver-layer merge).

    So beyond fix (B) there's a decision point for the maintainers: patch the Claude
    call sites and leave each driver hand-rolling its spawn options, or consolidate —
    one shared helper that builds provider child spawn options (explicit scoped cwd,
    plus an env sanitized with the logic already in terminal/Manager.ts), applied at
    the driver boundary. Consolidation closes both leak classes for all five providers
    at one chokepoint while keeping the blast radius small: it touches neither
    backendCwd (fix A), nor the terminal path, nor the session path, which the
    issue description already notes plumbs workspaceRoot correctly.

    I'd be happy to test Linux/AppImage.

  2. satyalyadav commented on Sep 27, 2026

    @satyalyadav
    Contributor

    On WSL, homeDirectory is the Windows profile, so backendCwd becomes C:\Users\<user> and wsl.exe starts the Linux backend in /mnt/c/Users/<user>. That value reaches provider probes and the servers they spawn:

    • DesktopEnvironment.ts:224 sets backendCwd from homeDirectory
    • main.ts:80 supplies NodeOS.homedir()
    • DesktopBackendConfiguration.ts:746 passes it as cwd to wsl.exe
    • OpenCodeDriver.ts:142 passes serverConfig.cwd as the OpenCode directory, and OpenCodeServerOwner gives the same value to the spawned opencode serve

    A T3-spawned OpenCode server was running here with cwd=/mnt/c/Users/<user>. Same value as #8955, which has a clean Codex repro. I could not reproduce the OpenCode timeout directly, so I am not claiming this explains every case.

    Fix: resolve backendCwd to the distro's Linux home in WSL mode. DesktopWslEnvironment.getUserHome at DesktopWslEnvironment.ts:1271 already does this for the folder picker. Doing it at backendCwd covers every provider instead of one, and keeps $HOME semantics since the Linux home is the backend's $HOME.

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