Skip to content

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

@renatogcarvalho

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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions