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
- Install a packaged desktop build to
/Applications.
- 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.
- Replace
claude on PATH with a wrapper that logs its cwd and exits, or use any wrapper that scopes to cwd.
- 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)
Summary
In packaged desktop builds, the Node server child process is deliberately spawned with
cwd = $HOME. Two functions inClaudeProvider.tsthen spawn theclaudebinary without specifying anycwdof 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 theclaudebinary 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 =$HOMEand either exposes the user's entire home directory to the agent or fails outright. The session path itself plumbs the project'sworkspaceRootcorrectly, 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
$HOMEin packaged builds.apps/desktop/src/app/DesktopEnvironment.ts:backendCwdflows throughDesktopBackendConfiguration.cwdintoDesktopBackendManager'sChildProcess.make(..., { cwd }). So the server'sprocess.cwd()is$HOMEin any installed build.2. Claude binary spawns inherit it.
apps/server/src/provider/Layers/ClaudeProvider.ts:probeClaudeCapabilitiescalls the SDK'sclaudeQuerywithout anycwdoption — the SDK then spawnsclaudewith the parent process's cwd.runClaudeCommandusesChildProcess.make(claudeSettings.binaryPath, args, { env, shell })with nocwdoption — same inheritance.Both inherit
$HOMEfrom the server process.3. Session path is fine, but it's gated behind the probe.
apps/server/src/orchestration/Layers/ProviderCommandReactor.tscorrectly resolveseffectiveCwdfrom the thread's bound project and passes it toproviderService.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
~/code/myprojecthas 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.$HOMEhappens to contain dot dirs the kernel blocks from being mount-overlaid (.Trashetc.), 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 insideClaudeProvider.Repro
/Applications.~/code/myproject). Start a new thread inside it — confirm the thread header shows the Project name.claudeonPATHwith a wrapper that logs its cwd and exits, or use any wrapper that scopes to cwd.$HOME, not the bound Project'sworkspaceRoot.Suggested fix
Two parts:
A. Decouple the file-picker default-open path from the server's
process.cwd().The packaged-build
backendCwd = homeDirectorylooks 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. SetbackendCwdto something neutral (e.g. the app's user-data directory, orappRoot).B. Pass an explicit
cwdto everyclaudespawn inClaudeProvider.ts.Both
probeClaudeCapabilities(via the SDK'scwdoption) andrunClaudeCommand(viaChildProcess.make'scwdoption) should pass an explicit, scoped path — at minimum, the user-data directory; ideally, the bound project'sworkspaceRootwhen 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
.Trashinteraction makes this most visible here, but the cwd leak itself is OS-independent)isPackaged === true)