Branch chip: remote repo path is resolved against the local filesystem, so remote sessions show `—`

Status Fixed / completed
Reported on v2.1.221
Maintainer reply None cached
Activity 0 comments · opened Aug 5, 2026 · closed Aug 10, 2026

*AI assisted with the generation of this report, but I have personally read and verified the
content to be accurate to my standards.*

Summary

In the Claude Desktop composer, the branch chip ("Branch to start from") renders an em dash for
sessions targeting a WSL distro. With the environment set to Local against a Windows-side
clone — same app process, same repo, seconds apart — it renders correctly.

Root cause, observed under DESKTOP_LOG_LEVEL=debug: the branch chip's data comes from
local repo-root discovery, which is handed the remote path. path.resolve maps the WSL path
/home/<user>/dev/claude-config onto the Windows drive root as
C:\home\<user>\dev\claude-config, the ancestor walk for .git fails at every level up to C:\,
and the memoized GIT_ROOT_NOT_FOUND result is reused for the life of the session.

The correct data was already available and is being ignored. getRemoteGitInfo fires 816
times and returns the right answer every time:

  • git.info returns {"isRepo":true,"branch":"main",…}observed directly on the app's own daemon
  • the client-side mapping turns that into a valid object
  • the IPC result validator accepts that object

So this is not missing plumbing. A correct branch is fetched over RPC and discarded, while a
parallel local-filesystem lookup that cannot possibly succeed decides what the chip displays.

Severity is higher than the em dash suggests. The local walk fails only because nothing
happens to exist at the resolved path — not because it detects that the path is remote. Where a
local directory does collide, the chip silently reports an unrelated repository's branch for a
remote session. That has already been reported on #52690.

Environment

| | |
|---|---|
| Claude Desktop | 1.25927.0 (MSIX / Store install, x64), commit 003700efafbc2ccb4b1177a5e637b14da381799e |
| CCD remote CLI | 2.1.221 |
| Remote helper | 5db5e4a12f88487e47c2c48259b69a2d630bb3f7, methods=19, features=[process.stdin.offset] |
| Windows | 11, build 10.0.26200.8875 |
| WSL | 2.7.11.0, kernel 6.18.33.2-2 |
| Distro | Ubuntu 26.04 LTS, WSL2 |
| git (WSL) | 2.53.0 |

Expected vs actual

Expected: with the environment set to a WSL distro and the directory set to a git repo
inside it, the branch chip shows the checked-out branch.

Actual: the chip renders , dimmed, indefinitely. Not a cold-start race — the query
re-runs on every composer interaction and never populates.

Screenshots

  1. WSL session[Ubuntu] [claude-config] [⑂ —] [☐ worktree], no + button.

<img width="714" height="237" alt="Image" src="https://github.com/user-attachments/assets/16dbafd6-c06d-4659-93ac-56b995b8e7fd" />

  1. Same, tooltip visible — names the control as "Branch to start from".

<img width="751" height="333" alt="Image" src="https://github.com/user-attachments/assets/612b7955-6f12-4ed2-8c60-782553a0b3d6" />

  1. Local session, same Windows machine[Local] [claude-config] [⑂ main] [☐ worktree] [+]. Note the + button present here and absent above.

<img width="995" height="324" alt="Image" src="https://github.com/user-attachments/assets/d771c789-e516-4178-b0b6-4e6a70e21ba4" />

1 and 3 differ only in the environment picker — same machine, same app process, same repo name.

Reproduction

  1. On Windows with WSL2, open Claude Desktop.
  2. Set the environment picker to a WSL distro (here: Ubuntu).
  3. Set the directory to a git repo inside the distro, accepting the trust prompt.
  4. Observe the branch chip: .
  5. For contrast, clone any repo to a Windows path, set the environment to Local, point at

it. The chip shows the branch.

The two paths

| | Local (C:\Users\<winuser>\…\claude-config) | WSL (/home/<user>/dev/claude-config) |
|---|---|---|
| Trust check | LocalSessions.checkTrust ✓ | LocalSessions.checkRemoteTargetTrust ✓ |
| Git info | no IPC call logged — in-process GitStatusService | LocalSessions.getRemoteGitInfo → RPC git.info, 816 calls |
| Branch chip | renders | |

Log line, once per poll, never accompanied by an error:

[info] LocalSessions.getRemoteGitInfo: target=wsl:Ubuntu, path=/home/<user>/dev/claude-config

Verified chain

Code is deminified from the shipped app.asar (1.25927.0), so symbol names are reconstructed,
not original. Offsets are into the extracted chunk files.

1. git.info returns a correct payload — observed on the live daemon

Queried over the running daemon's own socket, the same one the app is connected to:

{ "isRepo": true,
  "repo": "claude-config",
  "branch": "main",
  "root": "/home/<user>/dev/claude-config",
  "repoSlug": "<owner>/claude-config",
  "defaultBranch": "main" }

Method, for anyone reproducing: newline-delimited JSON-RPC 2.0 over the unix socket at
~/.claude/remote/run/<id>/rpc.sock, with the token from ~/.claude/remote/run/<id>/daemon.token
in a top-level auth field (json:"auth,omitempty" in the server's request struct).

import socket, json
run = "/home/<user>/.claude/remote/run/<id>"
tok = open(run + "/daemon.token").read().strip()
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM); s.connect(run + "/rpc.sock")
s.sendall((json.dumps({"jsonrpc":"2.0","id":1,"method":"git.info",
                       "params":{"path":"<repo>"},"auth":tok}) + "\n").encode())
buf = b""
while b"\n" not in buf: buf += s.recv(65536)
print(buf.decode())

git.list_branches{"isRepo":true,"branches":["main"]} and git.status
{"isRepo":true,"clean":true} are also correct.

2. The mapping produces a valid object

toGitInfo(conn, path) {
  const i = await conn.getGitInfo(path)         // rpcClient.call('git.info', { path })
  return (!i.isRepo || !i.branch) ? null
       : { repo: i.repoSlug ?? '', branch: i.branch,
           defaultBranch: i.defaultBranch || undefined, root: i.root }
}

isRepo is true and branch is "main", so the null branch is not taken. Result:
{ repo: "<owner>/claude-config", branch: "main", defaultBranch: "main", root: "/home/…" }.

rpcClient.call() resolves with the unwrapped result, not the envelope — confirmed both in
its handleMessage (n.resolve(t.result)) and by every sibling call site reading payload
fields directly (.pong, .entries, .branches, .success, .found).

getGitInfo has no capability gate, version check, or feature flag; it calls unconditionally.
(listBranches, by contrast, is gated on capabilities.methods.includes('git.list_branches').)

3. The IPC result validator accepts it

function F(e){ return !(!e || typeof e!=='object'
  || typeof e.repo !== 'string' || typeof e.branch !== 'string'
  || (e.defaultBranch !== undefined && typeof e.defaultBranch !== 'string')
  || (e.root !== undefined && typeof e.root !== 'string')) }

Applied in the handler:

let i = await t.getRemoteGitInfo(n, r)
if (!(i === null || F(i))) throw Error('Result from method "getRemoteGitInfo" … failed to pass validation')
return i

Every predicate is satisfied by the object from step 2 — repo defaults to '', branch is
guaranteed truthy by the guard above it, the rest are optional. It cannot reject anything
toGitInfo produces. No failed to pass validation lines appear in the logs.

4. …and the chip ignores it

Steps 1–3 establish that a correct branch reaches the IPC boundary. The chip nonetheless reads
, because it is not sourced from this result at all — it comes from a parallel local
filesystem lookup that cannot succeed for a remote path. See Root cause below, which is
observed in the debug log rather than inferred.

The composer itself is claude.ai web content rather than part of the local bundle — the IPC
channels are named …_$_claude.web_$_LocalSessions_$_getRemoteGitInfo — so the exact wiring on
the render side was not inspectable from the client machine. The main-process behaviour that
feeds it is fully observable, and is what the root-cause section documents.

Ruled out

  • git unavailable in WSL. git -C <repo> rev-parse --abbrev-ref HEADmain.
  • git unreachable from Windows. wsl.exe -d Ubuntu -e git -C <repo> rev-parse --abbrev-ref HEADmain, exit 0.
  • Repo state. .git/HEAD = ref: refs/heads/main; origin present; git status -sb## main...origin/main. Files ref backend, not reftable.
  • Directory not trusted. C:\Users\<winuser>\.claude.json has

projects["wsl:ubuntu:/home/<user>/dev/claude-config"].hasTrustDialogAccepted: true. The key
format matches what the trust check builds — the distro segment is lowercased on both sides.
The wsl:Ubuntu in logs comes from a separate display formatter that does not lowercase; it
is not the lookup key.

  • RPC transport and auth. The live query in step 1 succeeded over the app's own daemon

socket. The daemon logs no unauthorized or parse errors at any of the app's getRemoteGitInfo
timestamps.

  • Response shape or field naming. Go struct tags are json:"isRepo",

json:"branch,omitempty", json:"repoSlug", json:"defaultBranch", json:"root,omitempty"
matching the client exactly. Nothing is dropped by omitempty; every field is populated.

  • Envelope unwrapping. .call() resolves result (step 2).
  • Capability gating on git.info. None exists (step 2).
  • WSL-vs-SSH divergence in this path. One controller class serves both. getGitInfo,

expandRemoteTilde, ensureReady, and queryServerCapabilities contain no kind branch.
The kind === 'wsl' branches that do exist affect only transport-client construction,
connect timeout, reconnect gating, and shutdown.

  • Stale helper binary. The bundled manifest pins

5db5e4a12f88487e47c2c48259b69a2d630bb3f7, matching the deployed directory, and the logs show
[BinaryDeployment] Server binary up to date (5db5e4a1…), skipping upload. No
capabilities handshake failed and no continuing with existing remote fallback lines.

  • Stale app build. 1.25927.0 is current; [updater] MSIX detected confirms the running exe.
  • Missing Windows git. Installing Git for Windows changed nothing for the WSL target, and

the app was relaunched afterward. That the Local chip renders proves the app can spawn Windows
git — GitStatusService must run git symbolic-ref and git config --get remote.origin.url
to produce a branch name.

  • Silent throw in the IPC layer. The validator passes (step 3).

Prior art

#52690 — Show git branch in remote (SSH) workspace chip row
describes the identical symptom for SSH remotes on macOS: chip reads , chip is
non-interactive, and the + new-workspace button is missing. Filed 2026-04-24, auto-closed as
stale 2026-06-24 without a fix. Labelled enhancement, which is arguably the wrong
classification — see impact below.

This report adds three things that issue lacks:

  1. WSL, not just SSH. One controller class serves both kind: 'wsl' and kind: 'ssh'; the

git-info path contains no kind branch. Same defect, wider blast radius than "SSH only".

  1. Proof the data is already available. #52690 assumed the feature "just wasn't set up yet".

It is set up: getRemoteGitInfo fires 816 times, the RPC returns the correct branch, and the
result passes IPC validation. Nothing needs to be built — something downstream is dropping a
value it already has.

  1. A likely mechanism (below), consistent with a follow-up comment on that issue.

The missing + button reproduces here too: the Local chip row has it, the WSL row does not.
Same signature, different transport.

Root cause — observed

Running the app with DESKTOP_LOG_LEVEL=debug exposes it directly. The main process resolves the
remote path against the local Windows filesystem and walks its ancestors looking for a
.git directory:

[debug] [path-safety] isRealpathWithin denied C:\home\<user>\dev\claude-config\.git in C:\home\<user>\dev\claude-config: ENOENT
[debug] [path-safety] isRealpathWithin denied C:\home\<user>\dev\.git               in C:\home\<user>\dev: ENOENT
[debug] [path-safety] isRealpathWithin denied C:\home\<user>\.git                   in C:\home\<user>: ENOENT
[debug] [path-safety] isRealpathWithin denied C:\home\.git                          in C:\home: ENOENT
[debug] [path-safety] isRealpathWithin denied C:\.git                               in C:\: ENOENT

The session's directory is /home/<user>/dev/claude-config inside the WSL distro. On Windows,
path.resolve maps that POSIX-looking path onto the current drive root, giving
C:\home\<user>\dev\claude-config, which does not exist. The walk terminates at C:\ having
found no repository.

That is local repo-root discovery being handed a path belonging to another machine:

let n = path.resolve(e);
const root = n.substring(0, n.indexOf(path.sep) + 1) || path.sep;   // "C:\"
for (; n !== root; ) {
  if (check(path.join(n, '.git'), n)) return found(n);
  const parent = path.dirname(n);
  if (parent === n) break;
  n = parent;
}
return GIT_ROOT_NOT_FOUND

It is memoized (cache size 50, keyed on path), which is why 816 getRemoteGitInfo polls produce
only three walks — the first caches GIT_ROOT_NOT_FOUND and every later poll reuses it. The chip
therefore reads permanently rather than transiently.

The same log contains its own control. For the Local Windows clone at
C:\Users\<winuser>\Documents\dev\claude-config, no denial for that repo's own .git is ever
logged — the path exists, the check succeeds, the walk stops at depth one, and the chip renders.
Same function, same log file, both outcomes side by side.

The misresolution is systematic, not specific to one directory. A second WSL repo used for the
worktree test produced the same shape:

[debug] [path-safety] … path: 'C:\home\<user>\dev\wt-probe\.claude\worktrees\.git'

This also explains the misleading-branch report

A commenter on #52690 observed that *"if the same path exists on the local machine, it will show
the branch from that and is very confusing/misleading."* That follows directly. The walk does not
fail because the path is remote — nothing in it knows or cares that it is. It fails only because
nothing happens to exist at the resolved local location. Where something does exist, the walk
succeeds and the chip reports an unrelated local repository's branch for a remote session.

For WSL the collision requires a directory at C:\home\<user>\dev\<repo>, which is unusual — hence
rather than a wrong answer. For SSH remotes between two macOS or Linux hosts, where remote and
local paths share the same /Users/… or /home/… shape, collision is considerably more likely.

Impact beyond the branch display

The branch text itself is cosmetic. These are not:

  • Wrong branch displayed on local/remote path collision (reported on #52690). Silently

misleading about which branch a session will operate on — the opposite of the chip's purpose.

  • Branch chip is non-interactive, so branch switching from the composer is unavailable for

remote targets. Local targets can click through.

  • The + new-workspace button is absent from the remote chip row.
  • **Worktree creation is not affected — tested.** The chip is labelled "Branch to start from"

and sits beside the worktree checkbox, and git.worktree_create takes a sourceBranch, so
this looked like the place the defect could do real damage. It does not. In a purpose-built
repo checked out on feature-x with a second branch main and a commit unique to
feature-x, a WSL session with worktree enabled produced:

``
/home/<user>/dev/wt-probe 8009d1c [feature-x]
/home/<user>/dev/wt-probe/.claude/worktrees/<name> 8009d1c [claude/<name>]
``

The worktree forked from the checked-out branch, not from the default branch. With the chip
inert, no sourceBranch is supplied, and creation correctly falls through to HEAD. The log
records Using worktree "<name>" at … with no sourceBranch, and no git switch was
performed.

The residual risk is confined to the path-collision case: there the chip does populate, with
a foreign repository's branch, and that value would be passed as sourceBranch. Not
reproducible on this machine, so untested.

Unaffected, for scope: the CLI's own git awareness inside a remote session is correct (it reads
git directly in the distro), the diff viewer works (it spawns git via process.spawn, a
separate path), and session targeting/cwd is correct.

Conclusion

Two independent paths compute the branch for a session, and the wrong one wins for remote
targets:

| | source | result for a WSL session |
|---|---|---|
| getRemoteGitInfo → RPC git.info | the repo, in the distro | branch: "main" — correct |
| local repo-root discovery | C:\home\… on the Windows disk | GIT_ROOT_NOT_FOUND, memoized |

The chip reads the second. The suggested fix is to gate repo-root discovery on target kind: when
the session targets a remote (kind: 'wsl' or kind: 'ssh'), consume the getRemoteGitInfo
result rather than resolving a foreign path against the local filesystem.

The guard is worth having independently of the display bug. Resolving a remote path locally is
not merely unhelpful — when it accidentally succeeds it produces a confidently wrong answer, and
sourceBranch feeds a real git switch (see impact above).

---

Secondary finding: git hardening flags are absent from the helper

Unrelated to the above and not the cause here, but worth a look while this code is open.

The app has two ways of running git against a remote target:

  1. Spawned child via the process.spawn RPC — used by the diff viewer. Every invocation is

hardened:
``js
['-c','core.quotepath=false', '-c','safe.directory=*', '-c','core.fsmonitor=false']
env: { GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0' }
``

  1. The git.info / git.list_branches RPCs, implemented in the Go helper. The helper binary

contains none of those strings — safe.directory, core.quotepath, core.fsmonitor,
core.hooksPath, GIT_TERMINAL_PROMPT, GIT_OPTIONAL_LOCKS are all absent.

That asymmetry means a repository which trips git's dubious-ownership check would still render
diffs (path 1 passes safe.directory=*) while silently losing its branch chip (path 2 does
not). A likely way to hit it on WSL is a repo under /mnt/c, where ownership maps differently.
Not this reporter's situation — the repo is uid-1000-owned inside the distro's own filesystem,
and the helper's git commands were confirmed to succeed under the daemon's environment — but it
would produce exactly this symptom for someone else, and would be equally silent.

View original on GitHub ↗