Skip to content

fix(session-lock): identify a harness session on Windows/Git Bash - #6300

Open
cr101 wants to merge 5 commits into
kunchenguid:mainfrom
cr101:fix/windows-session-lock-identity
Open

cr101 wants to merge 5 commits into
kunchenguid:mainfrom
cr101:fix/windows-session-lock-identity

Conversation

@cr101

@cr101 cr101 commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Intent

Let a Firstmate session on Windows/Git Bash acquire its session lock instead of always dropping to read-only, without changing POSIX behavior.
This is the first small piece split out of #4803. It carries the session-identity work from #3553 by @lmktechnology, rebased onto current main with his authorship preserved, plus one follow-up commit that fixes review findings.
Walking the real Windows parent chain was rejected as unsafe: MSYS exec emulation leaves dangling parent ids that Windows can reissue to unrelated processes. A harness that publishes no session pid stays read-only.
It does not depend on the #506 platform seam, and either can adapt to the other.

What Changed

  • bin/fm-session-lock-lib.sh adds fm_ps_comm, fm_ps_args and fm_ps_ppid. They fall back to Cygwin's fixed-column ps output when ps -o is unsupported. When the POSIX ancestry walk finds nothing and fm_win_boundary_applies is true, the session identity now comes from the pid that the harness publishes (CLAUDE_PID). That pid must be live in the Windows process table (ps -W) and must match the harness executable before it is recorded as a tagged win:<pid>. The real Windows parent chain is never walked, and a harness that publishes no pid stays read-only. On Windows, fm-lock.sh now reports a Windows-specific error instead of the ancestry error.
  • A new fm_session_pid_valid predicate replaces the private numeric-only checks in every lock gate: fm-bootstrap.sh, fm-claude-stop-autoarm.sh, fm-lease.sh, fm-lock.sh, fm-session-start.sh, fm-sessionstart-run.sh, fm-startup-network.sh, fm_session_lock_owned_by_self, fm_session_lock_foreign_owner_live and fm_session_lock_inspect. It accepts the tagged shape only where fm_win_boundary_applies is true and the remainder is all digits, so on Linux/macOS a win:N lock stays inert as before. fm_harness_pid_alive checks a tagged holder for liveness against the Windows process table. fm-sessionstart-run.sh no longer strips non-digits from a holder value before its kill -0 check. fm-sessionstart-nudge.sh keeps its own ancestry walk and passes a tagged lock to fm_session_lock_owned_by_self.
  • tests/fm-session-lock-ancestry.test.sh gains Cygwin ps fallback and Windows tagged-identity cases next to the existing background-session tests. The Windows inputs use FM_TEST_CLAUDE_PID injection. docs/scripts.md and docs/sessionstart-nudge.md describe the Windows tagged identity.

Fixes #3396. Refs #4535 and #4539: the numeric-only readers in bin/fm-turnend-guard-cursor.sh, bin/fm-wake-lib.sh and the Pi extensions are unchanged here.

Known limitations

Risk Assessment

✅ Low: The tagged win: identity is accepted only when fm_win_boundary_applies and only with an all-digit remainder, so Linux and macOS readers reject it exactly as main does. The Cygwin ps fallbacks only run after the primary ps -o path returns nothing. Every changed lock reader goes through fm_session_pid_valid and the namespace-aware liveness check, and the new Cygwin-fake tests exercise behavior rather than grepping source.

Testing

On this real Windows 11 Git Bash host I drove the lock entrypoints against a disposable lab home: fm-lock.sh (acquire and status), fm-inbox.sh ready, fm-turnend-guard.sh --claude and fm-claude-stop-autoarm.sh. Two idle real claude.exe processes stood in for two sessions. All live scenarios passed: - Tagged acquire, refusal of a second session, live foreign-owner and owned-by-self. - Fail-closed handling of non-harness, missing and garbage pids, and of malformed tagged values. - Stale-holder recovery. - A Linux-reported platform stays inert, matching main. - The Stop guard lets a read-only session stop, where base 549e07f blocks it. The first lab run timed out in the unchanged claim-lock layer. A native symlink created under the /tmp mount reads back with a different path prefix than the lab path I had passed, so the owner check never matched. I fixed it by addressing the lab through its /tmp path and reran. The ancestry suite passes in full in a Linux container. That is an automated run, so the POSIX-intact scenario is recorded as untested rather than as a live pass. On Git Bash its 14 unit tests pass, but the end-to-end fixtures, which rely on Linux process reparenting, fail at both this head and base. No UI surface is involved, so the evidence is CLI transcripts. Live Windows Claude ownership is not claimed: no real Claude session ran, and no lab Claude primary was started because tmux is not installed on this host.

  • Live validation: ✅ go - 9 of 11 scenarios driven live against the product
Scenario Result Live Evidence
Windows session whose CLAUDE_PID names a live claude.exe acquires the lock: fm-lock.sh prints 'lock acquired: harness pid win:25492', state/.lock holds win:25492, and re-acquiring is idempotent ✅ pass live windows-live-lock-transcript.txt (S1, S1b)
fm-lock.sh status and fm-inbox.sh ready report a live tagged holder as live ('held by live harness pid win:25492'; lock state held, live_harness true) ✅ pass live windows-live-lock-transcript.txt (S2, S2b)
A second Windows session (another live claude.exe) is refused and the lock is unchanged ✅ pass live windows-live-lock-transcript.txt (S3): 'another live firstmate session holds the lock (pid win:25492)', exit 1
Owner sees owned_by_self=yes; second session sees owned_by_self=no and foreign_owner_live=yes (win:25492) ✅ pass live windows-live-lock-transcript.txt (S4)
Claude Stop guard in a read-only second session with a live tagged owner allows Stop (exit 0, 'OWNED BY ANOTHER LIVE SESSION'); base 549e07f blocked it (exit 2) ✅ pass live windows-live-turnend-guard.txt (head vs base control)
Adversarial: a CLAUDE_PID naming a live non-harness process (explorer.exe), an unset CLAUDE_PID, or a garbage CLAUDE_PID stays read-only with the Windows error, and the lock is not modified ✅ pass live windows-live-lock-transcript.txt (S5, S5b)
Adversarial: malformed tagged values win:7abc, win:, 'win:7 x' and win:-1 are rejected by fm_session_pid_valid and are not alive ✅ pass live platform-gate-and-malformed.txt (S7)
Adversarial (POSIX unchanged): with uname reporting Linux, a win:N lock is rejected, status/inbox read it exactly as base does, and the Stop auto-arm leaves a dead win:N lock unclaimed ✅ pass live platform-gate-and-malformed.txt (S8, base comparison); windows-live-dead-holder.txt (S9a); linux-view-autoarm-trace.txt
A dead tagged holder reads as stale on Windows, and another live session recovers the lock as win:12940, which status then reports as live ✅ pass live windows-live-dead-holder.txt (S9b, S9c)
POSIX session-lock behavior is intact: the full ancestry suite, including Linux end-to-end claim/arm fixtures and the new Windows/F1/F3 tests, passes on Linux ⏸️ untested no The prior payload did not establish a live result for this scenario. It came only from an automated test suite run in a disposable Linux container (ancestry-suite-linux-container.txt), not from drivin…
A real Claude Code session on Windows owns the home end-to-end (CLAUDE_PID published by the live harness) ⏸️ untested no The runbook's lab primary needs tmux on a private socket, and tmux is not on PATH on this Windows host. There is no repository-local tmux, and installing one system-wide is forbidden. Idle real claude…
Evidence: Live Git Bash lock transcript: acquire, status, inbox, refusal, foreign/self, adversarial pids

Source: Live Git Bash lock transcript: acquire, status, inbox, refusal, foreign/self, adversarial pids

== live Windows/Git Bash: MINGW64_NT-10.0-26200; head 0063438; lab /tmp/fm-lab.0gZwAY; MSYS=winsymlinks:nativestrict
== idle real claude.exe stand-ins (ps -W):
    31990   31985   31983      25492  ?         197608 22:14:49 /c/Users/Cristian/.local/bin/claude
    31991   31986   31983      12940  ?         197608 22:14:49 /c/Users/Cristian/.local/bin/claude

== S1 session A acquires (CLAUDE_PID=25492)
$ CLAUDE_PID=25492 timeout 90 bin/fm-lock.sh
lock acquired: harness pid win:25492
[exit 0]
state/.lock = win:25492

== S1b session A re-acquire is idempotent
$ CLAUDE_PID=25492 timeout 90 bin/fm-lock.sh
lock acquired: harness pid win:25492
[exit 0]
state/.lock = win:25492

== S2 status reports live tagged holder
$ timeout 60 bin/fm-lock.sh status
lock: held by live harness pid win:25492
[exit 0]

== S2b fm-inbox.sh ready lock field
$ timeout 60 bin/fm-inbox.sh ready
{"schema":"fm-primary-ready.v1","home":"Temp/fm-lab.0gZwAY","observed_at":"2026-10-01T09:19:14Z","lock":{"state":"held","pid":null,"live_harness":true},"wake_consumer":{"state":"unknown","reason":"supervision-model-unknown-for-home","beacon_age_seconds":null},"posture":{"state":"present"},"can_receive":"unknown"}
[exit 0]

== S3 session B (CLAUDE_PID=12940) is refused
$ CLAUDE_PID=12940 timeout 90 bin/fm-lock.sh
error: another live firstmate session holds the lock (pid win:25492); operate read-only until resolved
[exit 1]
state/.lock = win:25492

== S4 owned-by-self / foreign-owner from each session
$ CLAUDE_PID=25492 timeout 60 bash -c . bin/fm-session-lock-lib.sh; S=$FM_HOME/state; fm_session_lock_owned_by_self "$S" && echo owned_by_self=yes || echo owned_by_self=no; fm_session_lock_foreign_owner_live "$S" && echo "foreign_owner_live=yes ($FM_SESSION_LOCK_FOREIGN_OWNER_PID)" || echo foreign_owner_live=no
owned_by_self=yes
foreign_owner_live=no
[exit 0]
$ CLAUDE_PID=12940 timeout 60 bash -c . bin/fm-session-lock-lib.sh; S=$FM_HOME/state; fm_session_lock_owned_by_self "$S" && echo owned_by_self=yes || echo owned_by_self=no; fm_session_lock_foreign_owner_live "$S" && echo "foreign_owner_live=yes ($FM_SESSION_LOCK_FOREIGN_OWNER_PID)" || echo foreign_owner_live=no
owned_by_self=no
foreign_owner_live=yes (win:25492)
[exit 0]

== S5 adversarial: CLAUDE_PID names a live NON-harness Windows process
non-harness winpid=7960: 25 C:\Windows\explorer.exe 
$ CLAUDE_PID=7960 timeout 90 bin/fm-lock.sh
error: cannot identify this harness session on Windows: it publishes no session pid this build recognizes (see FM_WIN_HARNESS_PID_VARS in bin/fm-session-lock-lib.sh); operate read-only until resolved
[exit 1]
state/.lock unchanged = win:25492

== S5b adversarial: no published pid, garbage pid
$ timeout 90 bin/fm-lock.sh
error: cannot identify this harness session on Windows: it publishes no session pid this build recognizes (see FM_WIN_HARNESS_PID_VARS in bin/fm-session-lock-lib.sh); operate read-only until resolved
[exit 1]
$ CLAUDE_PID=12x timeout 90 bin/fm-lock.sh
error: cannot identify this harness session on Windows: it publishes no session pid this build recognizes (see FM_WIN_HARNESS_PID_VARS in bin/fm-session-lock-lib.sh); operate read-only until resolved
[exit 1]
state/.lock unchanged = win:25492
Evidence: Stop guard before/after: head allows Stop for the read-only session (exit 0), base 549e07f blocks it (exit 2)

Source: Stop guard before/after: head allows Stop for the read-only session (exit 0), base 549e07f blocks it (exit 2)

== S6 Claude Stop guard in read-only session B while live session A (win:25492) owns the home; supervision needed (state/demo-task.meta)
-- this PR (0063438):
$ [head] CLAUDE_PID=12940 fm-turnend-guard.sh --claude  (lock=win:25492)
{"systemMessage":"FIRSTMATE SUPERVISION IS OWNED BY ANOTHER LIVE SESSION: this read-only session cannot and should not arm or repair the watcher (lock owner pid win:25492). Allowing this turn to end safely; the owning session must restore supervision."}
[exit 0]

-- control, base 549e07f:
$ [base] CLAUDE_PID=12940 fm-turnend-guard.sh --claude  (lock=win:25492)
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
●  TURN WOULD END BLIND - SUPERVISION IS OFF
●  1 task(s) in flight, but no live watcher holds this home lock (last beat: never).
●  The Stop-owned auto-arm did not claim this home either, so recovery is NOT already under way.
●  watcher supervision needs Stop-owned automatic recovery; inspect the hook registration and startup status before ending the turn.
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[exit 2]
Evidence: Platform gate and malformed tagged values: real MINGW64 vs PATH-shim Linux, compared with base

Source: Platform gate and malformed tagged values: real MINGW64 vs PATH-shim Linux, compared with base

== S7 real Git Bash (uname -s=MINGW64_NT-10.0-26200), live owner win:25492
  [win:25492]  pid_valid=valid    harness_alive=alive
  [win:7abc]   pid_valid=rejected harness_alive=not-alive
  [win:]       pid_valid=rejected harness_alive=not-alive
  [win:7 x]    pid_valid=rejected harness_alive=not-alive
  [win:-1]     pid_valid=rejected harness_alive=not-alive
  [4242]       pid_valid=valid    harness_alive=not-alive
  status: lock: held by live harness pid win:25492

== S8 same home, same live owner, but platform reported as Linux (PATH-shim uname -> Linux)
  [win:25492]  pid_valid=rejected harness_alive=not-alive
  [win:7abc]   pid_valid=rejected harness_alive=not-alive
  [win:]       pid_valid=rejected harness_alive=not-alive
  [win:7 x]    pid_valid=rejected harness_alive=not-alive
  [win:-1]     pid_valid=rejected harness_alive=not-alive
  [4242]       pid_valid=valid    harness_alive=not-alive
  status: lock: stale (pid win:25492 dead or not a harness)
  inbox ready lock: "lock":{"state":"unknown","pid":null,"live_harness":null}
  stop guard (session B, Linux view): ●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ●  TURN WOULD END BLIND - SUPERVISION IS OFF 
  base 549e07f status under same Linux view: lock: stale (pid win:25492 dead or not a harness)
Evidence: Dead tagged holder: inert on a Linux-reported platform; stale on Windows and recovered by session B

Source: Dead tagged holder: inert on a Linux-reported platform; stale on Windows and recovered by session B

== S9 session A's claude.exe (winpid 25492) terminated; Windows process table now:
  (no row for 25492)
  lock = win:25492

== S9a adversarial (F1): dead win:25492 holder read on a Linux-reporting platform by session B's Stop auto-arm
  [exit 0 in 1s] output: <none>
  lock after = win:25492  (unchanged => inert, home not claimed)

== S9b real Windows view: status of dead tagged holder
  lock: stale (pid win:25492 dead or not a harness)
  inbox: "lock":{"state":"unknown","pid":null,"live_harness":null}

== S9c session B (CLAUDE_PID=12940) recovers the dead tagged lock
$ CLAUDE_PID=12940 fm-lock.sh
lock acquired: harness pid win:12940
  [exit 0] lock = win:12940
  status: lock: held by live harness pid win:12940
Evidence: bash -x trace: Stop auto-arm exits at fm_session_pid_valid when the platform reports Linux

Source: bash -x trace: Stop auto-arm exits at fm_session_pid_valid when the platform reports Linux

== S9a trace: Linux-reporting reader, dead holder win:25492, session B's Stop auto-arm (bash -x, identity section)
+ case "$AUTOARM_ATTEMPTS" in
+++ uname
++ case "$poll" in
+ fm_primary_scope_matches /tmp/fm-lab.0gZwAY/root-head /tmp/fm-lab.0gZwAY/state
+ RECOVER_SESSION_LOCK=0
+ fm_session_lock_owned_by_self /tmp/fm-lab.0gZwAY/state
+ fm_session_pid_valid win:25492
+ fm_win_untag_pid win:25492
+ case "$1" in
+ case "$winpid" in
+ fm_win_boundary_applies
+ case "$(uname -s 2> /dev/null)" in
++ uname -s
+ case "$1" in
+ LOCK_PID=win:25492
+ fm_session_pid_valid win:25492
+ fm_win_untag_pid win:25492
+ case "$1" in
+ case "$winpid" in
+ fm_win_boundary_applies
+ case "$(uname -s 2> /dev/null)" in
++ uname -s
+ case "$1" in
+ exit 0
lock after = win:25492
Evidence: Ancestry suite in a Linux container (all ok)

Source: Ancestry suite in a Linux container (all ok)

Linux 6.6.87.2-microsoft-standard-WSL2
ok - session-lock: a version-named Claude Code session is identified from its install path and argv[0]
ok - session-lock: a harness that is pid 1 of its own namespace is examined, not skipped
ok - session-lock: ordinary script paths under a harness directory are not harness processes
ok - session-lock: ownership stops at the first non-harness gap above the contiguous run
ok - session-lock: a live version-named session holding the lock is not mistaken for a stale owner
ok - session-lock: a trusted same-session id keeps owning a recycled background chain, and nothing weaker does
ok - session-lock: a trusted id anchors the lock on the model-loop process, anything else on the outermost pid
ok - session-lock: a Windows session is identified from its published pid across the severed parent link
ok - session-lock: a published Windows pid is confirmed against the process table before it is trusted
ok - session-lock: a tagged Windows pid is never resolved against the Cygwin process table
ok - session-lock: a published identity is accepted by every gate that reads the lock
ok - session-lock: a live tagged lock holder is a live foreign owner to a second Windows session
ok - session-lock: a Windows-tagged lock stays inert off Windows
ok - session-lock: a harness in the local process table resolves untagged when ps has no -o option
ok - session-lock e2e: a version-named session claims the home and arms supervision
ok - session-lock e2e: a session parented by a harness-named daemon claims the home and arms supervision
ok - session-lock e2e: a version-named session under a harness-named daemon keeps its own lock
ok - session-lock e2e: a background session keeps its lock and its supervision across a recycled helper chain
ok - session-lock: a same-session confirmation waits for the claim lock and refreshes a re-keyed id
ok - session-lock: a waiting confirmation does not steal another session's lock
ok - session-lock: a failed lock write restores the previous sidecar
ok - session-lock: a failed lock write removes a newly created sidecar
ok - session-lock: a verified reclaim keeps the new sidecar beside the new pid
Evidence: Ancestry suite on Git Bash at head (unit tests ok; Linux-only end-to-end fixture fails)

Source: Ancestry suite on Git Bash at head (unit tests ok; Linux-only end-to-end fixture fails)

ok - session-lock: a version-named Claude Code session is identified from its install path and argv[0]
ok - session-lock: a harness that is pid 1 of its own namespace is examined, not skipped
ok - session-lock: ordinary script paths under a harness directory are not harness processes
ok - session-lock: ownership stops at the first non-harness gap above the contiguous run
ok - session-lock: a live version-named session holding the lock is not mistaken for a stale owner
ok - session-lock: a trusted same-session id keeps owning a recycled background chain, and nothing weaker does
ok - session-lock: a trusted id anchors the lock on the model-loop process, anything else on the outermost pid
ok - session-lock: a Windows session is identified from its published pid across the severed parent link
ok - session-lock: a published Windows pid is confirmed against the process table before it is trusted
ok - session-lock: a tagged Windows pid is never resolved against the Cygwin process table
ok - session-lock: a published identity is accepted by every gate that reads the lock
ok - session-lock: a live tagged lock holder is a live foreign owner to a second Windows session
ok - session-lock: a Windows-tagged lock stays inert off Windows
ok - session-lock: a harness in the local process table resolves untagged when ps has no -o option
not ok - the fixture hook never finished
/tmp/fm-session-lock-ancestry.QC0FMZ/e2e-version-named/session.sh: line 12: /tmp/fm-session-lock-ancestry.QC0FMZ/e2e-version-named/state/hook.rc: No such file or directory
Evidence: Ancestry suite on Git Bash at base 549e07f (end-to-end fixtures fail there too)

Source: Ancestry suite on Git Bash at base 549e07f (end-to-end fixtures fail there too)

ok - session-lock: a version-named Claude Code session is identified from its install path and argv[0]
ok - session-lock: a harness that is pid 1 of its own namespace is examined, not skipped
ok - session-lock: ordinary script paths under a harness directory are not harness processes
ok - session-lock: ownership stops at the first non-harness gap above the contiguous run
ok - session-lock: a live version-named session holding the lock is not mistaken for a stale owner
ok - session-lock: a trusted same-session id keeps owning a recycled background chain, and nothing weaker does
ok - session-lock: a trusted id anchors the lock on the model-loop process, anything else on the outermost pid
not ok - a version-named session must claim its home and rewake: expected exit 2, got 0
- Outcome: ⚠️ 1 info across 1 run (16m23s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

✅ **Review** - passed

✅ No issues found.

⚠️ **Test** - 1 info
  • ℹ️ bin/fm-wake-lib.sh - On Git Bash, a lab home given as /c/Users/.../Temp/fm-lab.X makes fm-lock.sh acquire hang in the unchanged claim-lock layer (fm_lock_try_create in bin/fm-wake-lib.sh). The native symlink reads back as /tmp/..., so the owner comparison never matches. Addressing the lab through /tmp fixed it. This is the same environmental family as upstream Windows: fm_lock_try_create can never succeed without symlink privilege (ln -s silently degrades to a copy) #3267 and was not introduced by this change.
  • Live validation: ✅ go - 9 of 11 scenarios driven live against the product
Scenario Result Live Evidence
Windows session whose CLAUDE_PID names a live claude.exe acquires the lock: fm-lock.sh prints 'lock acquired: harness pid win:25492', state/.lock holds win:25492, and re-acquiring is idempotent ✅ pass live windows-live-lock-transcript.txt (S1, S1b)
fm-lock.sh status and fm-inbox.sh ready report a live tagged holder as live ('held by live harness pid win:25492'; lock state held, live_harness true) ✅ pass live windows-live-lock-transcript.txt (S2, S2b)
A second Windows session (another live claude.exe) is refused and the lock is unchanged ✅ pass live windows-live-lock-transcript.txt (S3): 'another live firstmate session holds the lock (pid win:25492)', exit 1
Owner sees owned_by_self=yes; second session sees owned_by_self=no and foreign_owner_live=yes (win:25492) ✅ pass live windows-live-lock-transcript.txt (S4)
Claude Stop guard in a read-only second session with a live tagged owner allows Stop (exit 0, 'OWNED BY ANOTHER LIVE SESSION'); base 549e07f blocked it (exit 2) ✅ pass live windows-live-turnend-guard.txt (head vs base control)
Adversarial: a CLAUDE_PID naming a live non-harness process (explorer.exe), an unset CLAUDE_PID, or a garbage CLAUDE_PID stays read-only with the Windows error, and the lock is not modified ✅ pass live windows-live-lock-transcript.txt (S5, S5b)
Adversarial: malformed tagged values win:7abc, win:, 'win:7 x' and win:-1 are rejected by fm_session_pid_valid and are not alive ✅ pass live platform-gate-and-malformed.txt (S7)
Adversarial (POSIX unchanged): with uname reporting Linux, a win:N lock is rejected, status/inbox read it exactly as base does, and the Stop auto-arm leaves a dead win:N lock unclaimed ✅ pass live platform-gate-and-malformed.txt (S8, base comparison); windows-live-dead-holder.txt (S9a); linux-view-autoarm-trace.txt
A dead tagged holder reads as stale on Windows, and another live session recovers the lock as win:12940, which status then reports as live ✅ pass live windows-live-dead-holder.txt (S9b, S9c)
POSIX session-lock behavior is intact: the full ancestry suite, including Linux end-to-end claim/arm fixtures and the new Windows/F1/F3 tests, passes on Linux ⏸️ untested no The prior payload did not establish a live result for this scenario. It came only from an automated test suite run in a disposable Linux container (ancestry-suite-linux-container.txt), not from drivin…
A real Claude Code session on Windows owns the home end-to-end (CLAUDE_PID published by the live harness) ⏸️ untested no The runbook's lab primary needs tmux on a private socket, and tmux is not on PATH on this Windows host. There is no repository-local tmux, and installing one system-wide is forbidden. Idle real claude…
  • Started two idle real claude.exe processes ((printf x; sleep 1800) | claude -p) and confirmed via PowerShell Get-Process that winpids 25492 and 12940 are ~.local\bin\claude.exe
  • Created a disposable lab with bin/fm-lab-home.sh create; every command ran with MSYS=winsymlinks:nativestrict FM_HOME=&lt;lab&gt;, env -u NO_MISTAKES_GATE -u CLAUDE_PID, and timeout
  • CLAUDE_PID=25492 bin/fm-lock.sh (acquire, then re-acquire), bin/fm-lock.sh status, bin/fm-inbox.sh ready
  • CLAUDE_PID=12940 bin/fm-lock.sh (second session is refused)
  • From each session, sourced bin/fm-session-lock-lib.sh and called fm_session_lock_owned_by_self and fm_session_lock_foreign_owner_live
  • Adversarial: CLAUDE_PID=&lt;explorer.exe winpid&gt;, CLAUDE_PID unset, and CLAUDE_PID=12x, each running bin/fm-lock.sh
  • fm-turnend-guard.sh --claude with a Stop payload, run from disposable plain clones at 0063438 and at base 549e07f (before/after control) while a live win:25492 owner holds the lock and a task is in flight
  • fm_session_pid_valid / fm_harness_pid_alive on win:25492, win:7abc, win:, 'win:7 x', win:-1 and 4242, on real MINGW64 and with a PATH-shim uname that reports Linux; also fm-lock.sh status, fm-inbox.sh ready, and the Stop guard under the Linux view, compared with base
  • Killed my own stand-in A (kill 31990). Then: fm-claude-stop-autoarm.sh under the Linux view (lock left unchanged; bash -x trace shows the exit at fm_session_pid_valid), fm-lock.sh status and inbox on Windows (stale), and CLAUDE_PID=12940 bin/fm-lock.sh (recovers the lock as win:12940)
  • bash tests/fm-session-lock-ancestry.test.sh on Git Bash, for this head and for base 549e07f
  • bash tests/fm-session-lock-ancestry.test.sh in a disposable firstmate-native-validation:tools-v2 Linux container built from git archive HEAD (all 23 tests ok)
  • Cleanup: stopped only the processes I started, removed the lab home and its clones, and confirmed the worktree is clean
✅ **Document** - passed

✅ No issues found.

⚠️ **Lint** - 1 warning
  • ⚠️ linter found issues (exit code 1)
✅ **Push** - passed

✅ No issues found.

lmktechnology and others added 5 commits October 1, 2026 20:49
Every session start on Cygwin (Git for Windows) refused the fleet lock and
dropped to read-only with "cannot locate harness process in ancestry", so
spawning, steering, merging, the wake-queue drain, and supervision repair were
skipped on every start. Two independent causes, both on the identity path.

Cygwin's ps has no -o option at all and fails the whole invocation with
"unknown option -- o", so the ancestry walk aborted on its first hop. The walk
now reads comm, args, and ppid through accessors that fall back to Cygwin's
fixed ps columns, leaving the procps/BSD path unchanged.

That alone does not resolve the session: the parent link from a shell the
harness spawns does not cross the Cygwin boundary, and Cygwin reports that
shell's PPID as 1, so no walk can reach a harness that is a native Windows
process. Identity is instead taken from the session pid the harness publishes
and confirmed against the Windows process table before it is used - the pid
must still be live and its executable must independently identify a verified
harness - so an absent, stale, or non-harness value is discarded rather than
bound.

Walking the real Windows parent chain was implemented and then removed as
unsafe. MSYS emulates exec by spawning a fresh Windows process and exiting the
old one, so intermediate shells vanish and a child's recorded parent is
routinely a pid that no longer exists; Windows never reparents an orphan, so
that dangling id stays and can be reissued to an unrelated process. Following
it can bind a home's lock to the wrong process, which is the failure this file
exists to prevent. A harness that publishes nothing stays unresolved, which
leaves the session read-only exactly as before.

Windows pids are tagged rather than stored bare. They are a different namespace:
kill -0 reports a live Windows process as dead, and the number can collide with
an unrelated live Cygwin pid. The tag makes the value non-numeric, so a consumer
that treats it as a local pid - including a future kill - refuses it instead of
acting on the wrong process.

fm-sessionstart-nudge.sh carried a private second copy of the ownership walk and
so stayed wrong after the owner was fixed, nudging a session that already held
the lock. It now asks the owning function.

Verified on Windows 11 (Git Bash, Cygwin ps 3.4.10): the lock is acquired,
reports its holder, is idempotent, and refuses a bogus, dead, or non-harness
published pid. Regressions cover both platform departures behind a fake process
table, so they run on Linux and macOS CI too. The pre-existing e2e failure in
this suite on Windows is unchanged from main; it cannot exec its symlinked
fixture.

Refs kunchenguid#3396

Claude-Session: https://claude.ai/code/session_01F9T29YDqYSkQeDTC2Nfi7h
(cherry picked from commit 8b281b7)
Delegating the nudge to fm_session_lock_owned_by_self changed the question it
asks. The nudge asks whether a process in this ancestry took the lock; the
ownership predicate additionally requires a verified harness in that ancestry,
so a session whose lock was written by a plain shell started being nudged to
run session start again. tests/fm-sessionstart-nudge.test.sh pins the looser
contract deliberately.

The walk stays local, now reading ppid through the portable accessor so it also
survives a ps with no -o option, and defers to the ownership predicate only for
a Windows-tagged holder, which is not in this process table at all.

(cherry picked from commit 0baf64f)
…the lock

The Windows identity added in this branch introduced a second shape into
state/.lock. Six gates read that field, and each one answered "is this value
usable" with its own inline numeric test, so every one of them read a valid
Windows holder as malformed.

The visible cost was concentrated in startup completion: the record was never
written, and the clear/compact check that consumes it could never match, so
every clear or compact on Windows repeated the full startup sequence. The
deferred network sweeps reported ownership as changed when it had not, and the
Stop auto-arm treated a dead Windows session as an unreadable lock rather than
a recoverable one. fm-lease.sh was the outlier: rather than refusing a value it
could not use, `tr -cd '0-9'` reduced the tag to its digits and produced a
number naming an unrelated process in the local table. It happened to fail
closed downstream, but deriving a wrong-namespace pid is precisely what the tag
exists to prevent, so it now takes the value whole and requires a local pid.

fm_session_pid_valid is now the single owner of that question and every gate
delegates to it, so a third identity shape cannot split them again.

Verified against the shipped bytes of each gate with a tagged lock: startup
completion now records and is recognized (main's gate reruns the full startup
on the same input), both network-ownership gates authorize their sweeps, and a
tagged lock yields no lease holder pid instead of 7204.

(cherry picked from commit 9256182)
… every lock reader

Accept win:<digits> only where fm_win_boundary_applies, with an all-digit
remainder, so a Linux/macOS reader of a win:N lock stays inert as before.
The sessionstart nudge routes its tagged case through fm_win_untag_pid.
fm_session_lock_foreign_owner_live and fm_session_lock_inspect now validate
through fm_session_pid_valid and resolve a tagged holder through
fm_harness_pid_alive, so a live win:N holder reads as a live foreign owner
and as held; unverifiable tagged holders stay unknown.
@greptile-apps

greptile-apps Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 3/5

[Medium risk] Extends session-lock identity to support Windows/Git Bash.

The PR is not safe to merge until the Windows identity path keeps a separate harness that inherits a Claude pid read-only.

Reviews (1) · Last reviewed commit: "no-mistakes(document): Document Windows ..."

Comment on lines +245 to +251
winpid=${!var:-}
case "$winpid" in
''|*[!0-9]*) continue ;;
esac
comm=$(fm_win_command "$winpid") || continue
fm_harness_process_matches "$comm" "$comm" || continue
printf '%s%s\n' "$FM_WIN_PID_PREFIX" "$winpid"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Inherited PID grants lock ownership

If a separate harness is launched from a Claude pane on Windows/Git Bash, it can inherit that pane’s CLAUDE_PID without publishing its own session pid. When its local ancestry does not identify a harness, this fallback checks only that the inherited pid names a live, harness-shaped executable. It then treats that pid as the caller’s identity, so the separate session can pass fm_session_lock_owned_by_self for the Claude session’s lock instead of remaining read-only.

How this was verified: The Windows fallback accepts a live harness-shaped pid from CLAUDE_PID without checking that it belongs to the caller, and ownership compares that identity directly with the lock.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged. Windows exposes no reliable parent chain to bind the published CLAUDE_PID to the caller: MSYS exec emulation leaves dangling parent ids that Windows can reissue, which is why walking that chain was rejected. The alternative is that every Windows session stays read-only. So the published pid, confirmed live and harness-shaped in the Windows process table, is the identity, and a separate harness started from inside a Claude window can inherit it. This is now recorded under Known limitations in the PR description. Linux and macOS behavior is unchanged.

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate: first stamp vs tip 549e07f37fd73aa01d74cd126b1111c99175abed.

Closes verification: GitHub closing ref + body Fixes #3396 — yes, closes #3396 (Windows/Git Bash session lock always refuses). Queue hint closesReadyForPr=[3267] is a sort hint only (learning #2993): body Known limitations explicitly says #3267 (symlink privilege / fm_lock_try_create) is not changed here. Do not treat this PR as closing #3267.

HEAD 724da265aac5b1dda17073b6ff20f51e4679854c. Attestation MATCH. NM SUCCESS. Behavior portable + Herdr green. Lint 1 FAILURE — tip-wide shellcheck: out of memory / exit 251 (same envelope family as #5620 / #6271). Lint 2 SUCCESS. Greptile FAILURE 3/5 P1 (inherited CLAUDE_PID can grant ownership to a nested harness) — author acknowledged under Known limitations; recorded as security FYI only (not otherwise-ready; FM-LEARN #3168 — no Firstmate flag / no waiting-captain).

contract-class: restore — tip still cannot resolve harness identity across the Cygwin/Windows boundary (ps -o unsupported; parent link severed), so unconfigured Windows/Git Bash sessions stay read-only against the existing session-lock contract. Tagged win:<pid> from published harness pid + Windows process-table confirm restores that path without changing POSIX ancestry.

VISION (compressed): One captain/interface — aligns (restores usable session ownership, not a new captain surface). Authority explicit — aligns (fail-closed when pid unpublished/non-harness; nested-inherit limitation documented). Scripts/judgment — aligns (deterministic ps/pid bridge). Restart non-event — aligns (tagged liveness/stale recovery). Delegation spine — n/a. Fleet outlives vendor — aligns (platform-shaped adapter for Cygwin boundary). Scope — aligns (lock mechanics, not workshop).

Needed: green Lint 1 (or tip envelope landing) before merge consideration; Greptile P1 already documented as accepted Windows limitation. Not auto-merge eligible this pass. Firstmate flag no.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Windows/Git Bash: session lock always refuses - harness ancestry cannot be resolved

3 participants