[BUG] 2.1.248 uid-mapping check reads an empty /proc/self/uid_map as a collapsed namespace, disabling all background sessions on Android

Status Open
Reported on v2.1.251
Maintainer reply None cached
Activity 0 comments · opened Aug 31, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet (closest is #86567, whose fix is what introduced this; #80093 is a different cause — a 45s reachability timeout, not a refusal)
  • [x] This is a single bug report
  • [x] I am using the latest version of Claude Code (2.1.251)

What's Wrong?

Since 2.1.248, no background session can be started or re-entered on Android (Termux, unrooted,
aarch64). Sending a conversation to the agent view, /fork, and background agents all fail
immediately, and claude daemon status reports the control socket as unreachable.

The cause is the uid-mapping detection added alongside the fix for #86567 (which shipped, per that
issue's closing comment, "in the release after v2.1.247"). Before using the daemon socket, the CLI
decides whether it is running in a user namespace with no uid mapping, and treats an empty
/proc/self/uid_map as proof that it is
. From the bundled JS, with the minifier's names kept:

function j(e){
  let t = process.getuid?.(), n;
  try { n = R(e.readFileSync("/proc/self/uid_map", {encoding:"utf8"})); } catch { n = void 0; }
  let r;
  try { r = I(e.readFileSync("/proc/sys/kernel/overflowuid", {encoding:"utf8"})); } catch { r = void 0; }
  let o = r ?? 65534;
  if (n === void 0) return t === o;          // unreadable -> compare against overflow uid
  if (h(n)) return !1;                       // full identity map -> fine
  return n.length === 0 || t !== void 0 && t === o;   // <-- empty -> "collapsed"
}

On Android that file is openable and reads as zero bytes for every process, while the device has
no user namespace at all. So n parses to a zero-length array, n.length === 0 is true, and the
guard throws.

The n === void 0 branch one line above would have returned the correct answer: the app uid is not
the overflow uid, so an unreadable file yields false and the daemon binds. Only the empty-file case
misclassifies.

Note the difference in blast radius from #86567: that one disabled cross-session messaging silently.
This check also gates the daemon control socket, so on an affected platform background sessions,
the agent view and /fork stop working entirely.

What Should Happen?

An empty uid_map should fall through to the overflow-uid comparison the function already performs,
rather than short-circuiting to "collapsed". Dropping n.length === 0 || from the final return is
sufficient, and does not weaken the check: a process genuinely inside a user namespace with no
mapping reports the overflow uid from getuid(), which the remaining comparison already catches.

Gating the whole check behind the existence of /proc/self/ns/user would be equivalent and states
the intent more directly.

Error Messages/Logs

In the agent view, under the prompt:

Couldn't reach the background service (refusing to use the daemon socket: this process runs in a
user namespace without a uid mapping, so directory and peer ownership cannot be verified (start it
with a mapping, e.g. unshare -Ur)) — run 'claude daemon status'

The session row then reads couldn't start in the background — press Enter to retry, and retrying
repeats it. claude daemon status:

bg sessions:
  sock dir:     .../cc-daemon-<id>/<id>
  control.sock: unreachable (refusing to use the daemon socket: this process runs in a user
                namespace without a uid mapping, so directory and peer ownership cannot be
                verified (start it with a mapping, e.g. unshare -Ur))
  bg workers:   0 in roster.json (control unreachable)

daemon.log records nothing at all after the upgrade — the refusal happens before the supervisor
starts, so there is no supervisor line to log.

Steps to Reproduce

  1. On Android (Termux, unrooted), run any 2.1.248+ build.
  2. Send a session to the background, or run /fork.
  3. The refusal above appears immediately.

The relevant platform facts, which need no Claude Code to observe:

$ python -c "print(repr(open('/proc/self/uid_map','rb').read()))"
b''

$ ls /proc/self/ns/
cgroup  ipc  mnt  net  time  time_for_children  uts      # no 'user'

uid_map, gid_map and setgroups all open and read as empty, while a name that genuinely is not
there fails properly — so the emptiness is not "file missing", and code cannot tell the two apart by
catching:

/proc/self/uid_map          open OK  bytes=0
/proc/self/gid_map          open OK  bytes=0
/proc/self/setgroups        open OK  bytes=0
/proc/self/bogus_xyz        open ERR FileNotFoundError

This is the platform, not the sandbox Claude Code runs in: a service-supervisor process with
TracerPid: 0, entirely outside any tracer or wrapper, reads exactly the same way.

Claude Model

Opus 5

Is this a regression?

Yes

Last Working Version

2.1.247

Claude Code Version

2.1.251

Platform

Linux (Android/Termux, aarch64)

Operating System

Android 17 (API 37)

Terminal/Shell

Termux / bash

Additional Information

Bisected by streaming each published linux-arm64 tarball and counting the error string, without
installing anything:

2.1.246 -> 0
2.1.247 -> 0
2.1.248 -> 2

Things that do not work around it, all measured:

  • No flag or environment variable skips the check. rI(), which resolves the control socket

path, calls the guard unconditionally, and so does the directory-creation path.
--messaging-socket-path covers only the messaging socket — the CLI already passes that to itself,
so it is not an escape hatch here. Nothing memoising uidsCollapse accepts an override.

  • Patching the embedded JavaScript has no effect. Changing || to && at both call sites is

length-preserving and each pattern occurs exactly once, but the readable JS is not what executes:
the binary carries a separate compiled constant pool holding the same strings, and the runtime uses
that. Worth noting on its own — the patched binary still started and still reported its version, so
nothing verifies the binary's own integrity. It was reverted byte-for-byte.

  • The wrapper is not involved, per the TracerPid: 0 measurement above.

So on this platform the only remedy is pinning to 2.1.247.

Related: #86567 (the messaging-socket case this detection was added for), #89478 and #79606 (a
different Android/userns interaction, in the sandbox's seccomp path rather than the socket path).

View original on GitHub ↗