From 263f5d7f21b99c9f82f167c94bb8797aeb538553 Mon Sep 17 00:00:00 2001 From: Evan Alter Date: Fri, 9 Oct 2026 21:07:02 -0500 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=85=20test:=20resolve=20the=20real=20?= =?UTF-8?q?python3=20before=20HOME=20is=20sandboxed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a machine where python3 is an asdf shim, the shim resolves its tool version relative to $HOME and exits 126 the moment a test overrides it. The deny-guard doctor test looked the interpreter up after setup() had already sandboxed HOME, so it failed at the lookup itself — locally only, which is why CI never showed it. setup() now resolves the interpreter first and re-exposes it under a stub on PATH, the way tests/denyguard.bats does, so every test in the file can use python3. The next commit's pseudo-terminal helper needs the same. Co-Authored-By: Claude Opus 5.5 --- tests/claude-profiles.bats | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/tests/claude-profiles.bats b/tests/claude-profiles.bats index 3e6754d976..f51302f54c 100644 --- a/tests/claude-profiles.bats +++ b/tests/claude-profiles.bats @@ -13,6 +13,11 @@ # tests/chezmoi.bats guards that the file renders at all. setup() { + # python3 is resolved to its real interpreter before HOME is sandboxed, and + # re-exposed under a stub on PATH below: an asdf shim (as on this machine) + # resolves relative to $HOME and exits 126 the moment that is overridden. + REAL_PYTHON3='' + REAL_PYTHON3="$(python3 -c 'import sys; print(sys.executable)' 2>/dev/null)" || REAL_PYTHON3='' export HOME="$BATS_TEST_TMPDIR/home" mkdir -p "$HOME" export GIT_CONFIG_GLOBAL="$BATS_TEST_TMPDIR/gitconfig" @@ -41,6 +46,10 @@ setup() { printf '#!/bin/sh\necho "CLAUDE_CONFIG_DIR=${CLAUDE_CONFIG_DIR-UNSET}"\n' \ > "$BATS_TEST_TMPDIR/bin/claude" chmod +x "$BATS_TEST_TMPDIR/bin/claude" + if [ -n "$REAL_PYTHON3" ]; then + printf '#!/bin/sh\nexec "%s" "$@"\n' "$REAL_PYTHON3" > "$BATS_TEST_TMPDIR/bin/python3" + chmod +x "$BATS_TEST_TMPDIR/bin/python3" + fi export PATH="$BATS_TEST_TMPDIR/bin:$PATH" DOTFUNCTIONS="$BATS_TEST_DIRNAME/../dot_functions" @@ -160,14 +169,8 @@ claude_in() { @test "claude-doctor: names the deny guard's rule count and fail-closed wiring" { repo=$(make_repo 'git@github.com:octo-personal/some-repo.git') - # denyguard itself needs a real python3: an asdf shim (as on this - # machine) resolves relative to $HOME and breaks under the sandboxed one - # these tests already use, which has nothing to do with the doctor line - # under test. - command -v python3 >/dev/null || skip "python3 not installed" - REAL_PYTHON3="$(python3 -c 'import sys; print(sys.executable)')" - printf '#!/bin/sh\nexec "%s" "$@"\n' "$REAL_PYTHON3" > "$BATS_TEST_TMPDIR/bin/python3" - chmod +x "$BATS_TEST_TMPDIR/bin/python3" + # denyguard itself needs a real python3 (see setup). + [ -n "$REAL_PYTHON3" ] || skip "python3 not installed" ln -s "$BATS_TEST_DIRNAME/../bin/executable_denyguard" "$BATS_TEST_TMPDIR/bin/denyguard" mkdir -p "$HOME/.claude-personal" From 6b4795cdd2b6d51ba84f8569dca472af3de47844 Mon Sep 17 00:00:00 2001 From: Evan Alter Date: Fri, 9 Oct 2026 21:07:03 -0500 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=A8=20feat:=20=F0=9F=9A=B8=20claude?= =?UTF-8?q?=20asks=20before=20starting=20a=20second=20session=20in=20the?= =?UTF-8?q?=20same=20directory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing `claude` where a session is already running starts a second writer on the same tree, and a fresh prompt gives no sign the first is there. The wrapper now lists the live sessions in the directory and asks `Start another here anyway? [y/N]` — a warning with a way past it, never a refusal. claude_sessions_here reads the registry each profile keeps in /sessions/.json (the files postbox already reads), across every mapped profile plus the directory this launch resolved to, since a literal CLAUDE_PROFILE or per-repo pin can sit outside every mapping. A match is the same real directory with a live pid. Only a launch somebody is sitting at is asked: no terminal, print mode, a help or version flag, or a first argument shaped like a subcommand all pass untouched, so hooks, the postbox courier and pipes never block. It fails open. `git config claude.second-session-check false` turns it off for anyone who runs several sessions in one checkout on purpose, and claude-doctor's new `running:` line shows what the wrapper would ask about. Testing the prompt needs a pseudo-terminal. zsh's zpty returns short reads on macOS and pty.spawn before Python 3.10 never returns there once the child exits, so the test helper forks one by hand. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 28 ++++++++ dot_functions | 74 ++++++++++++++++++- tests/claude-profiles.bats | 144 ++++++++++++++++++++++++++++++++++++- 3 files changed, 243 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 50421b4c32..91a5e82411 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -509,6 +509,34 @@ in another account's history and resume *nothing* instead of failing. a repo resolving somewhere its origin does not explain should say why — and `routes` marks the pinned cell with `*` like the other routers. +**A second session in the same directory is asked about, not refused.** Two +sessions in one directory are two writers on one tree, and a fresh prompt +gives no sign the first is there. `claude()` therefore reads the registry +every profile keeps of its live sessions — `/sessions/.json`, +removed on exit (observed layout, not an interface; postbox reads the same +files) — through `claude_sessions_here`, which checks **every** mapped profile +plus the directory this launch resolved to (a literal `CLAUDE_PROFILE` or +per-repo pin can sit outside every mapping), since the tree is shared +whichever login is running in it. A hit lists each +session (name, pid, status, profile) and asks `Start another here anyway? +[y/N]`. Only a launch somebody is sitting at is asked: no terminal on stdin or +stderr, `-p`/`--print`, a help or version flag, or a first argument shaped like +a subcommand (`claude mcp …` — by shape, one bare lowercase word, because a +list of the CLI's subcommands would go stale; the price is that a one-word +prompt, `claude fix`, is not asked either) passes untouched, so hooks, the +postbox courier and anything piped never block on a question nobody can +answer. It fails open, and it matches the directory exactly: a session started +in a subdirectory of the same checkout is not seen. Running several sessions +in one checkout on purpose is a workflow, not a mistake, so `git config +claude.second-session-check false` turns the question off — policy in +machine-local git config, like the push gates. `claude-doctor`'s `running:` +line lists the sessions the wrapper would find here — not whether a given +launch would be asked, which also depends on how it is invoked — and says +when the check is off. Testing the prompt +needs a pseudo-terminal, and the two obvious tools both fail on macOS — zsh's +`zpty` returns short reads, and `pty.spawn` before Python 3.10 never returns +once the child exits — so `tests/claude-profiles.bats` forks one by hand. + **Each profile must be logged in once** with `claude auth login`; credentials live in the macOS Keychain and cannot be copied between profiles — verified, copying `.claude.json` does not carry a session. diff --git a/dot_functions b/dot_functions index 7af2362d75..fa0a36fe02 100644 --- a/dot_functions +++ b/dot_functions @@ -349,8 +349,32 @@ function bin_trace() { # or a literal directory. With no mapping at all, this is plain `claude` on the # default ~/.claude config dir. function claude() { - local dir alias opener; + local dir alias opener here line; + local -a sess; dir="$(claude_profile_dir)" || dir=''; + # A second session in a directory that already has one puts two writers + # on the same tree, and nothing about a fresh prompt says the first is + # there. So say so, and ask — a warning with a way past it, never a + # refusal. Only a launch somebody is sitting at is asked: no terminal, + # print mode, a version/help flag, or a first argument shaped like a + # subcommand (`claude mcp …` — the shape, because a list of them would go + # stale) all pass untouched, so hooks, pipes and headless callers never + # block on a question nobody can answer. A helper that fails or finds + # nothing launches as before. Running several sessions in one checkout + # on purpose is a workflow, not a mistake: `git config + # claude.second-session-check false` turns the question off. + if [[ -t 0 && -t 2 && ! $1 =~ '^[a-z][a-z-]*$' ]] && (( ! ${argv[(I)(-p|--print|-h|--help|-v|--version)]} )) && [[ "$(command git config --get claude.second-session-check 2>/dev/null)" != false ]]; then + here="$(claude_sessions_here $dir 2>/dev/null)"; + if [[ -n $here ]]; then + print -ru2 -- "claude: already running in $PWD:"; + for line in ${(f)here}; do + sess=("${(@ps:\t:)line}"); + print -ru2 -- " ${sess[3]} pid ${sess[1]} ${sess[4]} (${sess[2]:t})"; + done; + read -q '?Start another here anyway? [y/N] ' || { print -u2; return 1; }; + print -u2; + fi; + fi; ( # The default profile must run with CLAUDE_CONFIG_DIR UNSET, not set # to its own path — setting the variable at all switches Claude Code @@ -419,6 +443,39 @@ function claude() { ) } +# The live Claude Code sessions whose cwd is this directory, across every +# profile — a second writer in the tree is the problem whichever login it +# runs under. One line each: "\t\t\t". Read +# from the registry each profile keeps in `/sessions/.json` +# and clears on exit (observed layout, not a documented interface; postbox +# reads the same files). Arguments are further profile directories to look in: +# the one this launch resolved to, which a literal CLAUDE_PROFILE or per-repo +# pin can put outside every mapping. Shared by claude() and claude-doctor. +function claude_sessions_here() { + emulate -L zsh; + local here=${PWD:A} d f json pid name state; + local -aU dirs; + dirs=("$HOME/.claude" $CLAUDE_CONFIG_DIR $@ ${(f)"$(command git config --get-regexp '^claude\.profile\.' 2>/dev/null | command cut -d' ' -f2-)"}); + dirs=(${dirs/#\~/$HOME}); + for d in $dirs; do + for f in $d/sessions/<->.json(N); do + # ponytail: regex over the JSON text, since this runs on every + # launch and a parser costs a process. A cwd holding a JSON + # escape (a quote, a backslash, non-ASCII) is not matched, so + # that session goes unreported; parse properly if it ever bites. + json="$(<$f)" 2>/dev/null || continue; + [[ $json =~ '"cwd"[[:space:]]*:[[:space:]]*"([^"]*)"' && ${match[1]:A} == $here ]] || continue; + [[ $json =~ '"pid"[[:space:]]*:[[:space:]]*([0-9]+)' ]] || continue; + pid=$match[1]; + kill -0 $pid 2>/dev/null || continue; + name=${here:t} state=-; + [[ $json =~ '"name"[[:space:]]*:[[:space:]]*"([^"]*)"' ]] && name=$match[1]; + [[ $json =~ '"status"[[:space:]]*:[[:space:]]*"([^"]*)"' ]] && state=$match[1]; + print -r -- "$pid"$'\t'"$d"$'\t'"$name"$'\t'"$state"; + done; + done; +} + # Can a link opened from this shell land in a browser window somebody is # looking at? macOS always has `open` and a screen; elsewhere it takes # xdg-open and a display. Over ssh the answer is no even on a mac — a window @@ -573,7 +630,7 @@ function claude-as() { function claude-doctor() { local origin acct map dir cred email slug pin; print -r -- "wrapper: $(whence -w claude)"; - print -r -- "defined: $(defined_trace claude_profile_dir claude_browser_alias claude_browser_can_open gh_account_for_cwd git_origin_slug bin_trace)"; + print -r -- "defined: $(defined_trace claude_profile_dir claude_sessions_here claude_browser_alias claude_browser_can_open gh_account_for_cwd git_origin_slug bin_trace)"; print -r -- "binary: $(bin="$(whence -p claude)" && bin_trace "$bin" || print -r -- 'NOT FOUND on PATH')"; print -r -- "cwd: $PWD"; origin="$(command git config --get remote.origin.url 2>/dev/null)"; @@ -606,6 +663,19 @@ function claude-doctor() { dir="$HOME/.claude"; bare=1; print -r -- "launch: $dir (bare claude fallback)"; fi; + # What claude() would ask about before launching here: sessions already + # live in this directory, in any profile. + local -a running; + running=(${(f)"$(claude_sessions_here $dir 2>/dev/null)"}); + if (( ${#running} )); then + if [[ "$(command git config --get claude.second-session-check 2>/dev/null)" == false ]]; then + print -r -- "running: ${#running} in this directory (pid ${(j:, :)running%%$'\t'*}) — claude.second-session-check is false, so claude does not ask"; + else + print -r -- "running: ${#running} in this directory (pid ${(j:, :)running%%$'\t'*}) — claude asks before starting another"; + fi; + else + print -r -- "running: "; + fi; if [[ $dir == "$HOME/.claude" ]]; then # The default profile authenticates via the shared Keychain entry and # records no account metadata on disk — presence is all we can check. diff --git a/tests/claude-profiles.bats b/tests/claude-profiles.bats index f51302f54c..d27cbf20a9 100644 --- a/tests/claude-profiles.bats +++ b/tests/claude-profiles.bats @@ -156,7 +156,7 @@ claude_in() { run zsh -c "source '$DOTFUNCTIONS'; cd '$repo'; claude-doctor" [ "$status" -eq 0 ] [[ "$output" == *"wrapper: claude: function"* ]] - [[ "$output" == *"defined: ok (6 helpers)"* ]] + [[ "$output" == *"defined: ok (7 helpers)"* ]] [[ "$output" == *"binary: $BATS_TEST_TMPDIR/bin/claude"* ]] [[ "$output" == *"account: personal-account"* ]] [[ "$output" == *"launch: $HOME/.claude-personal"* ]] @@ -743,3 +743,145 @@ NO_OPENAS_PATH="$BATS_TEST_TMPDIR/bin:/usr/bin:/bin" run zsh -c "source '$DOTFUNCTIONS'; cd '$repo'; BROWSER=firefox claude-doctor" [[ "$output" == *"browser: "* ]] } + +# --- a second session in the same directory --------------------------------- +# +# claude() asks before launching where a session is already live. The registry +# it reads is `/sessions/.json`; these fixtures write that +# file with a pid that is certainly alive (this bats process) or certainly not +# (a reaped child). + +# make_session [name] +make_session() { + mkdir -p "$1/sessions" + printf '{"pid":%s,"sessionId":"fixture","cwd":"%s","kind":"interactive","name":"%s","status":"idle"}\n' \ + "$2" "$3" "${4:-fixture-session}" > "$1/sessions/$2.json" +} + +# Run `claude "$@"` in $PTY_DIR on a pseudo-terminal — the question is only +# asked of a launch somebody is sitting at — and answer it with $PTY_ANSWER, +# typed ahead so a launch that asks nothing simply leaves it unread. A +# hand-rolled fork-and-read rather than the obvious tools: zsh's zpty returns +# short reads on macOS, and pty.spawn before Python 3.10 never returns there +# once the child exits. +pty_claude() { + [ -n "$REAL_PYTHON3" ] || skip "python3 not installed" + printf "source '%s'; cd '%s'; claude %s; print rc=\$?\n" "$DOTFUNCTIONS" "$PTY_DIR" "$*" \ + > "$BATS_TEST_TMPDIR/pty-inner.zsh" + run python3 -c ' +import os, pty, sys +pid, fd = pty.fork() +if pid == 0: + os.execvp("zsh", ["zsh", sys.argv[1]]) +os.write(fd, sys.argv[2].encode()) +while True: + try: + data = os.read(fd, 4096) + except OSError: + break + if not data: + break + sys.stdout.buffer.write(data) +os.waitpid(pid, 0) +' "$BATS_TEST_TMPDIR/pty-inner.zsh" "$PTY_ANSWER" +} + +@test "sessions: lists a live session in this directory, in any mapped profile" { + repo=$(make_repo 'git@github.com:octo-personal/some-repo.git') + true & dead=$! + wait "$dead" + make_session "$HOME/.claude-personal" "$$" "$repo" in-this-dir + make_session "$HOME/.claude-personal" "$dead" "$repo" exited + make_session "$HOME/.claude" "$PPID" "$BATS_TEST_TMPDIR" elsewhere + run zsh -c "source '$DOTFUNCTIONS'; cd '$repo'; claude_sessions_here" + [ "$status" -eq 0 ] + [ "$output" = "$$ $HOME/.claude-personal in-this-dir idle" ] + # The other profile's session is found from its own directory too. + run zsh -c "source '$DOTFUNCTIONS'; cd '$BATS_TEST_TMPDIR'; claude_sessions_here" + [ "$output" = "$PPID $HOME/.claude elsewhere idle" ] + run zsh -c "source '$DOTFUNCTIONS'; cd '$repo'; claude-doctor" + [[ "$output" == *"running: 1 in this directory (pid $$) — claude asks before starting another"* ]] +} + +@test "sessions: nothing live here prints nothing, and the doctor says so" { + repo=$(make_repo 'git@github.com:octo-personal/some-repo.git') + run zsh -c "source '$DOTFUNCTIONS'; cd '$repo'; claude_sessions_here" + [ "$status" -eq 0 ] + [ -z "$output" ] + run zsh -c "source '$DOTFUNCTIONS'; cd '$repo'; claude-doctor" + [[ "$output" == *"running: "* ]] +} + +@test "sessions: found with no profile mapped at all, and in a literal CLAUDE_PROFILE directory" { + # A public-only clone has no claude.profile.* mapping: the default + # directory is still looked in, and so is a directory only this launch names. + git config --file "$GIT_CONFIG_GLOBAL" --unset claude.profile.work-account + git config --file "$GIT_CONFIG_GLOBAL" --unset claude.profile.personal-account + repo=$(make_repo 'git@github.com:someone-else/some-repo.git') + make_session "$HOME/.claude" "$$" "$repo" default-dir + run zsh -c "source '$DOTFUNCTIONS'; cd '$repo'; claude_sessions_here" + [ "$output" = "$$ $HOME/.claude default-dir idle" ] + rm -r "$HOME/.claude/sessions" + make_session "$BATS_TEST_TMPDIR/custom-profile" "$$" "$repo" literal-dir + run zsh -c "source '$DOTFUNCTIONS'; cd '$repo'; claude_sessions_here" + [ -z "$output" ] + run zsh -c "source '$DOTFUNCTIONS'; cd '$repo'; CLAUDE_PROFILE='$BATS_TEST_TMPDIR/custom-profile' claude-doctor" + [[ "$output" == *"running: 1 in this directory (pid $$) — claude asks before starting another"* ]] + export CLAUDE_PROFILE="$BATS_TEST_TMPDIR/custom-profile" + PTY_DIR=$repo PTY_ANSWER=n pty_claude + [[ "$output" == *"literal-dir pid $$ idle (custom-profile)"* ]] + [[ "$output" == *"rc=1"* ]] +} + +@test "claude: a launch with no terminal is never asked, session or not" { + repo=$(make_repo 'git@github.com:octo-personal/some-repo.git') + make_session "$HOME/.claude-personal" "$$" "$repo" + claude_in "$repo" + [ "$status" -eq 0 ] + [ "$output" = "CLAUDE_CONFIG_DIR=$HOME/.claude-personal" ] +} + +@test "claude: warns about a live session here, and declining launches nothing" { + repo=$(make_repo 'git@github.com:octo-personal/some-repo.git') + make_session "$HOME/.claude-personal" "$$" "$repo" in-this-dir + PTY_DIR=$repo PTY_ANSWER=n pty_claude + [[ "$output" == *"claude: already running in $repo:"* ]] + [[ "$output" == *"in-this-dir pid $$ idle (.claude-personal)"* ]] + [[ "$output" == *"rc=1"* ]] + [[ "$output" != *"CLAUDE_CONFIG_DIR="* ]] +} + +@test "claude: answering y gets past the warning and launches" { + repo=$(make_repo 'git@github.com:octo-personal/some-repo.git') + make_session "$HOME/.claude-personal" "$$" "$repo" + PTY_DIR=$repo PTY_ANSWER=y pty_claude + [[ "$output" == *"claude: already running in $repo:"* ]] + [[ "$output" == *"CLAUDE_CONFIG_DIR=$HOME/.claude-personal"* ]] + [[ "$output" == *"rc=0"* ]] +} + +@test "claude: claude.second-session-check false turns the question off, and the doctor says so" { + repo=$(make_repo 'git@github.com:octo-personal/some-repo.git') + make_session "$HOME/.claude-personal" "$$" "$repo" + git config --file "$GIT_CONFIG_GLOBAL" claude.second-session-check false + PTY_DIR=$repo PTY_ANSWER=n pty_claude + [[ "$output" != *"already running"* ]] + [[ "$output" == *"CLAUDE_CONFIG_DIR=$HOME/.claude-personal"* ]] + run zsh -c "source '$DOTFUNCTIONS'; cd '$repo'; claude-doctor" + [[ "$output" == *"running: 1 in this directory (pid $$) — claude.second-session-check is false, so claude does not ask"* ]] +} + +@test "claude: a subcommand, print mode or a version flag is not a second session" { + repo=$(make_repo 'git@github.com:octo-personal/some-repo.git') + make_session "$HOME/.claude-personal" "$$" "$repo" + for args in 'mcp list' '-p hello' '--version' '--model sonnet --print hello'; do + PTY_DIR=$repo PTY_ANSWER=n pty_claude $args + [[ "$output" != *"already running"* ]] + [[ "$output" == *"CLAUDE_CONFIG_DIR=$HOME/.claude-personal"* ]] + done + # No session here: an ordinary launch is not asked either. + rm -r "$HOME/.claude-personal/sessions" + PTY_DIR=$repo PTY_ANSWER=n pty_claude + [[ "$output" != *"already running"* ]] + [[ "$output" == *"rc=0"* ]] +}