From d49867ec41f3c667fc19e0401f2b0e9a99fc4597 Mon Sep 17 00:00:00 2001 From: Lien Chen Date: Thu, 12 Nov 2026 18:52:17 +0900 Subject: [PATCH 1/8] feat(install): report a rejected state root at install and doctor time, and offer to fix it The state writer refuses a state root another account can write to. On a native Windows host with a Codex sandbox account that is the default location, so every state-writing pmctl command stopped after an install that looked fine. - doctor fails with the reason and the fix instead of leaving the first pmctl call to be refused. - install.sh ends with the same warning; --fix-state-root (or a yes at an interactive prompt) creates ~/.pm-dispatch-state owner-only, confirms the guard accepts it, and on native Windows records it as the user's PM_DISPATCH_STATE_ROOT. An existing directory is checked, never re-permissioned; the check stays on. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01ESW7P8BvZ5Crn6vi6EA2J7 --- CHANGELOG.md | 1 + .../script-variable-consumers.tsv | 5 + docs/platform-support.md | 6 ++ install.sh | 12 ++- runtime/bin/doctor.sh | 15 +++ runtime/lib/state-root-fix.sh | 93 +++++++++++++++++++ tests/shell/test-doctor.sh | 17 ++++ tests/shell/test-install.sh | 27 ++++++ 8 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 runtime/lib/state-root-fix.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 53899d9a..58cb669e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Versions follow [Semantic Versioning](https://semver.org/). ### Fixed +- **A rejected state root is reported at install and doctor time, and install can fix it.** The state writer refuses a state root another account can write to, which on a native Windows host with a Codex sandbox account is the default location, so every `pmctl task`, `dispatch` and `gate` call stopped after an install that had looked fine. `doctor` now fails with the reason (the first offending principal) and the fix, and `install.sh` ends with the same warning. `install.sh --fix-state-root`, or a yes at the prompt when a person is at the terminal, creates `~/.pm-dispatch-state` (owner-only; an existing directory is checked, never re-permissioned), confirms the guard accepts it, and on native Windows records it as the user's `PM_DISPATCH_STATE_ROOT`. The check itself is not relaxed. - **Gate: an exhausted executor quota is named as such on a real run.** The quota check read the files an adapter banner names, but a gate run through `pmctl dispatch run` has no banner in the reviewer's log; it has `run: ` and a `.footer` beside the log that names the executor's own stderr and event stream. On a real run that hit the Codex usage limit the failure was reported as an unexplained "transport failure" and retried once. The check now follows the footer (and still reads a banner in a directly dispatched log), so the report carries the executor's reset time and no retry is spent. - **Memory on native Windows.** Claude Code on Windows names a project's memory directory after the drive-letter path with every character that is not a letter or digit turned into `-` (`C--Users-Jo-Doe-repo`); `find_memory_dir` now also tries that name, so a repository on a native Windows host finds its memory. A native `sqlite3` ends rows with CR LF; the usage reader now strips it, so the last-access day is no longer read as 0 and cards are no longer all ranked oldest. Other platforms are unchanged. diff --git a/docs/architecture/script-variable-consumers.tsv b/docs/architecture/script-variable-consumers.tsv index c6566245..770acfa1 100644 --- a/docs/architecture/script-variable-consumers.tsv +++ b/docs/architecture/script-variable-consumers.tsv @@ -144,6 +144,7 @@ HOME HOME runtime/lib/memory.sh production HOME HOME runtime/lib/pmctl-config.sh production HOME HOME runtime/lib/pmctl-memory-config.sh production HOME HOME runtime/lib/portable.sh production +HOME HOME runtime/lib/state-root-fix.sh production HOME HOME runtime/lib/state-paths.sh production HOME HOME tools/lint/bootstrap-shellcheck.sh production HOME HOME tools/lint/lint-pmctl-commands.sh production @@ -161,6 +162,7 @@ MSYS2_ARG_CONV_EXCL MSYS2_ARG_CONV_EXCL runtime/lib/gate-result-verify.sh produc MSYS2_ARG_CONV_EXCL MSYS2_ARG_CONV_EXCL runtime/lib/gate-reviewer-contract.sh production MSYS2_ARG_CONV_EXCL MSYS2_ARG_CONV_EXCL runtime/lib/jq-lf.sh production MSYS2_ARG_CONV_EXCL MSYS2_ARG_CONV_EXCL runtime/lib/pmctl-gate.sh production +MSYS2_ARG_CONV_EXCL MSYS2_ARG_CONV_EXCL runtime/lib/state-root-fix.sh production MSYS2_ARG_CONV_EXCL MSYS2_ARG_CONV_EXCL ops/diagnostics/run-ci-lints.sh production MSYS2_ARG_CONV_EXCL MSYS2_ARG_CONV_EXCL ops/diagnostics/run-tests-in-wsl.sh production MSYS_NO_PATHCONV MSYS_NO_PATHCONV hosts/claude/bin/install-guards.sh production @@ -211,6 +213,7 @@ OSTYPE OSTYPE runtime/lib/jq-lf.sh production OSTYPE OSTYPE runtime/lib/memory.sh production OSTYPE OSTYPE runtime/lib/pmctl-context.sh production OSTYPE OSTYPE runtime/lib/portable.sh production +OSTYPE OSTYPE runtime/lib/state-root-fix.sh production OSTYPE OSTYPE tests/bin/run-tests.sh production OSTYPE OSTYPE tools/eval/gate-test-gap-live-eval.sh production OSTYPE OSTYPE tools/generate/gate-structural-validator.sh production @@ -371,6 +374,7 @@ PM_DISPATCH_REPOS_ROOT PM_DISPATCH_REPOS_ROOT runtime/lib/repo-layout.sh product PM_DISPATCH_REPOS_ROOT PM_DISPATCH_REPOS_ROOT tools/lint/lint-portable-repo-paths.sh production PM_DISPATCH_ROTATE_MAX_BYTES PM_DISPATCH_ROTATE_MAX_BYTES runtime/lib/state-writer.sh production PM_DISPATCH_STATE_ROOT PM_DISPATCH_STATE_ROOT hosts/claude/lib/doctor.sh production +PM_DISPATCH_STATE_ROOT PM_DISPATCH_STATE_ROOT install.sh production PM_DISPATCH_STATE_ROOT PM_DISPATCH_STATE_ROOT ops/release/clean-install-smoke.sh production PM_DISPATCH_STATE_ROOT PM_DISPATCH_STATE_ROOT ops/release/release-verify.sh production PM_DISPATCH_STATE_ROOT PM_DISPATCH_STATE_ROOT ops/release/upgrade-smoke-v0.10-v0.11.sh production @@ -380,6 +384,7 @@ PM_DISPATCH_STATE_ROOT PM_DISPATCH_STATE_ROOT runtime/lib/pmctl-context.sh produ PM_DISPATCH_STATE_ROOT PM_DISPATCH_STATE_ROOT runtime/lib/pmctl-gate.sh production PM_DISPATCH_STATE_ROOT PM_DISPATCH_STATE_ROOT runtime/lib/pmctl-worktree.sh production PM_DISPATCH_STATE_ROOT PM_DISPATCH_STATE_ROOT runtime/lib/state-paths.sh production +PM_DISPATCH_STATE_ROOT PM_DISPATCH_STATE_ROOT runtime/lib/state-root-fix.sh production PM_DISPATCH_STATE_ROOT PM_DISPATCH_STATE_ROOT runtime/lib/state-writer.sh production PM_DISPATCH_TEST_COMMAND_IDENTITY PM_DISPATCH_TEST_COMMAND_IDENTITY runtime/bin/pr-gate.sh production PM_DISPATCH_TEST_FORCE_JQ_LF PM_DISPATCH_TEST_FORCE_JQ_LF tests/lib/test-harness.sh test diff --git a/docs/platform-support.md b/docs/platform-support.md index ed6f42df..80c97c7e 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -382,6 +382,12 @@ things to know: ``` `PM_DISPATCH_ALLOW_UNSAFE_STATE_ROOT=1` turns the check off; do not use it to get past this. + + `bash runtime/bin/doctor.sh` reports this at setup time (`[FAIL] state root ... is rejected`), and `install.sh` ends with the same + warning. `bash install.sh --fix-state-root` does the steps above for you: it creates `~/.pm-dispatch-state` with an + owner-only ACL (an existing directory is only checked), confirms the guard accepts it, and on native Windows sets the + user environment variable `PM_DISPATCH_STATE_ROOT`; restart open terminals, Claude Code and Codex afterwards. + Without the flag, an interactive install asks first and a scripted install only warns. - **`PM_DISPATCH_STATE_ROOT` (and `XDG_DATA_HOME`) must be written as a POSIX path, `/c/Users/...`, not `C:/Users/...`.** The gate's run directory is derived from the state root and its supervisor refuses a drive-letter run directory. `pmctl gate run` checks the state root before it starts anything diff --git a/install.sh b/install.sh index 7acd023d..62cb1815 100755 --- a/install.sh +++ b/install.sh @@ -5,7 +5,7 @@ # Per-file symlinks: ~/.claude/agents/ may contain agents from other sources alongside. # # Usage: -# ./install.sh [--dry-run] [--profile minimal|full] [--verify] +# ./install.sh [--dry-run] [--profile minimal|full] [--verify] [--fix-state-root] # # --profile is forwarded to install-guards.sh and selects whether to wire adapter # bash guards (adapters//bash-guard.sh, manifest-driven via needs_bash_guard). @@ -15,6 +15,10 @@ # minimal skip adapter bash guards # (omit) auto-detect: codex on PATH → full, else minimal # +# --fix-state-root: when the pmctl state root is one the safety check rejects (another account can write it), create +# ~/.pm-dispatch-state (owner-only) and use it as PM_DISPATCH_STATE_ROOT. Without the flag install only reports the +# problem, and offers the same repair when a person is at the terminal. Never changes an existing directory. +# # --verify runs all preflight test suites before installing. # Skipped by default; recommended when contributing or after updating. # @@ -55,6 +59,7 @@ unset _jq_lf_dir DRY_RUN=0 VERIFY=0 +FIX_STATE_ROOT=0 PROFILE="" ENABLED_HOSTS=() SELECTED_HOSTS=(claude) @@ -63,6 +68,7 @@ while [[ $# -gt 0 ]]; do case "$1" in --dry-run) DRY_RUN=1; shift ;; --verify) VERIFY=1; shift ;; + --fix-state-root) FIX_STATE_ROOT=1; shift ;; --enable-codex-command-guard) ENABLED_HOSTS+=(codex); shift ;; --enable-host) [[ $# -ge 2 ]] || { echo "install: --enable-host requires a value" >&2; exit 2; } @@ -132,6 +138,8 @@ fi # shellcheck disable=SC1091 . "$REPO_ROOT/runtime/lib/install-receipt.sh" # shellcheck disable=SC1091 +. "$REPO_ROOT/runtime/lib/state-root-fix.sh" +# shellcheck disable=SC1091 . "$REPO_ROOT/runtime/lib/allowlist.sh" # shellcheck disable=SC1091 . "$REPO_ROOT/runtime/lib/dispatch-common.sh" @@ -764,6 +772,7 @@ if [[ "$_INSTALL_CLAUDE" -eq 0 ]]; then echo "install: product receipt write failed" >&2 exit 3 fi + [[ "$DRY_RUN" -eq 1 ]] || state_root_install_preflight "$REPO_ROOT" "$FIX_STATE_ROOT" echo "Done." [[ "$DRY_RUN" -eq 1 ]] && echo "(no changes made — re-run without --dry-run to apply)" exit 0 @@ -977,6 +986,7 @@ if [[ "$_COPY_FALLBACK_COUNT" -gt 0 && "$DRY_RUN" -eq 0 ]]; then echo " Re-run install.sh after pulling to keep copied files up to date." fi +[[ "$DRY_RUN" -eq 1 ]] || state_root_install_preflight "$REPO_ROOT" "$FIX_STATE_ROOT" echo "Done." if [[ "$DRY_RUN" -eq 1 ]]; then echo "(no changes made — re-run without --dry-run to apply)" diff --git a/runtime/bin/doctor.sh b/runtime/bin/doctor.sh index 32a99660..77f229fd 100755 --- a/runtime/bin/doctor.sh +++ b/runtime/bin/doctor.sh @@ -725,6 +725,20 @@ check_detached_runs() { fi } +# The state root is where runs, tasks, decisions and gate results are written, and the canonical writer refuses a root +# another principal can write to (issue #592). Say so here, at setup time, rather than leaving the first `pmctl task`, +# `pmctl dispatch` or `pmctl gate` call to be refused. Read-only: `state status` only inspects the root. +check_state_root() { + local pmctl="$REPO_ROOT/cli/pmctl" status store reasons + [[ -x "$pmctl" ]] || return 0 + status="$("$pmctl" state status --json --cd "$REPO_ROOT" 2>/dev/null)" || return 0 + [[ "$(jq -r 'if .safe_root == false then "false" else "true" end' <<<"$status" 2>/dev/null)" == "false" ]] || { emit_check state-root ok "state root is safe to write"; return; } + store="$(jq -r '.store_root // ""' <<<"$status" 2>/dev/null)" + reasons="$(jq -r '(.safe_root_reasons // []) | join("; ")' <<<"$status" 2>/dev/null)" + emit_check state-root fail "state root ${store:-?} is rejected (${reasons:-unsafe}): pmctl task, dispatch, gate and other state-writing commands will refuse to run" \ + "bash '${REPO_ROOT}/install.sh' --fix-state-root # makes ~/.pm-dispatch-state (only you can write) the state root; see docs/platform-support.md" +} + # Read-only parent-operation diagnostics. The operation projection is owned by # the canonical writer; doctor only uses state status to find its partition and # then reports non-terminal records with their producer-specific reconcile path. @@ -924,6 +938,7 @@ main() { check_memory_dir check_frontmatter_lint check_detached_runs + check_state_root check_parent_operations check_usage_tracker_path diff --git a/runtime/lib/state-root-fix.sh b/runtime/lib/state-root-fix.sh new file mode 100644 index 00000000..63b07d65 --- /dev/null +++ b/runtime/lib/state-root-fix.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# state-root-fix.sh — tell, at install time, that the state root will be refused, and offer to move it. +# +# The canonical writer refuses a state root another principal can write to (issue #592). On a host where the +# default location inherits such a grant (a native Windows profile with a Codex sandbox account, for example), every +# state-writing pmctl command would stop, so install says so and, when asked, points PM_DISPATCH_STATE_ROOT at a new +# directory only the user can write. It never changes the ACL of an existing directory and never turns the check off. +# +# Sourcing this file only defines functions. + +# jq: install.sh loads runtime/lib/jq-lf.sh before it sources this library, and a definition here must never replace +# that fuller function (CC-648). Only a library sourced on its own (a test fixture) and without a jq function adds -b. +if ! declare -F jq >/dev/null 2>&1; then + # CC-594: native Windows jq writes CRLF to a pipe/file; -b keeps LF (see runtime/lib/jq-lf.sh) + case "${OSTYPE:-}" in msys*|cygwin*) if type -P jq >/dev/null 2>&1; then jq() { command jq -b "$@"; }; fi ;; esac +fi + +# state_root_unsafe_reason +# Prints ": " and returns 0 when the effective state root is rejected; returns 1 when it is +# safe or cannot be inspected. Read-only. +state_root_unsafe_reason() { + local repo="$1" pmctl status + pmctl="$repo/cli/pmctl" + [[ -x "$pmctl" ]] || return 1 + status="$("$pmctl" state status --json --cd "$repo" 2>/dev/null)" || return 1 + [[ "$(jq -r 'if .safe_root == false then "false" else "true" end' <<<"$status" 2>/dev/null)" == "false" ]] || return 1 + jq -r '"\(.store_root): \((.safe_root_reasons // []) | join("; "))"' <<<"$status" 2>/dev/null +} + +# state_root_private_dir — where the repair puts the state root. +state_root_private_dir() { printf '%s\n' "${PM_DISPATCH_PRIVATE_STATE_ROOT:-$HOME/.pm-dispatch-state}"; } + +# state_root_fix +# Creates the private directory (owner-only), checks the canonical guard accepts it, and on native Windows records +# it as the user's PM_DISPATCH_STATE_ROOT. Elsewhere it prints the line to add to a shell profile. Returns 0 on +# success, 1 when the directory could not be made safe (nothing is persisted then). +state_root_fix() { + local repo="$1" dir check + dir="$(state_root_private_dir)" + if [[ -e "$dir" && ! -d "$dir" ]]; then + printf 'install: %s exists and is not a directory; not touching it\n' "$dir" >&2; return 1 + fi + if [[ ! -d "$dir" ]]; then + mkdir -p "$dir" || return 1 + case "${OSTYPE:-}" in + msys*|cygwin*) + # SIDs for SYSTEM and Administrators, so this also works on a localized Windows. A new directory only: + # an existing one is checked, never re-permissioned. + icacls "$(cygpath -w "$dir")" /inheritance:r /grant:r "$USERNAME:(OI)(CI)F" "*S-1-5-18:(OI)(CI)F" "*S-1-5-32-544:(OI)(CI)F" >/dev/null || return 1 ;; + *) chmod 700 "$dir" || return 1 ;; + esac + fi + check="$(PM_DISPATCH_STATE_ROOT="$dir" "$repo/cli/pmctl" state status --json --cd "$repo" 2>/dev/null | jq -r 'if .safe_root == true then "safe" else (.safe_root_reasons // [] | join("; ")) end' 2>/dev/null)" || check="not inspected" + if [[ "$check" != safe ]]; then + printf 'install: %s is not accepted as a state root (%s); nothing was changed\n' "$dir" "$check" >&2; return 1 + fi + case "${OSTYPE:-}" in + msys*|cygwin*) + # A user-level variable: every new terminal, Claude Code and Codex session picks it up. Written as a POSIX path + # (docs/platform-support.md); MSYS must not rewrite it into a Windows path on the way to setx. + MSYS2_ARG_CONV_EXCL='*' setx.exe PM_DISPATCH_STATE_ROOT "$dir" >/dev/null || return 1 + printf 'install: state root set to %s (user environment variable PM_DISPATCH_STATE_ROOT).\n' "$dir" + printf ' Restart open terminals, Claude Code and Codex so they see it.\n' ;; + *) + printf 'install: %s is ready. Add to your shell profile, then open a new shell:\n' "$dir" + printf ' export PM_DISPATCH_STATE_ROOT="%s"\n' "$dir" ;; + esac +} + +# state_root_install_preflight +# For install.sh, after a real install. Silent when the state root is fine. Otherwise prints what is wrong and +# repairs it when asked (--fix-state-root) or when a person at a terminal answers yes. Always returns 0: the +# install itself succeeded; this only says whether the tools it installed can write their state. +state_root_install_preflight() { + local repo="$1" fix="${2:-0}" reason answer="" + reason="$(state_root_unsafe_reason "$repo")" || return 0 + printf '\nWARNING: the pmctl state root is rejected, so pmctl task, dispatch, gate and the other state-writing\n' + printf ' commands will refuse to run until it is changed:\n %s\n' "$reason" + if [[ "$fix" -ne 1 ]]; then + if [[ -t 0 && -t 1 ]]; then + printf 'Create %s (writable only by you) and use it as the state root? [y/N] ' "$(state_root_private_dir)" + read -r answer || answer="" + [[ "$answer" == [yY]* ]] && fix=1 + fi + fi + if [[ "$fix" -eq 1 ]]; then + state_root_fix "$repo" || printf 'install: the state root was not changed; see docs/platform-support.md\n' >&2 + else + printf 'Fix: re-run with --fix-state-root to use %s, or set PM_DISPATCH_STATE_ROOT to a directory only you can\n' "$(state_root_private_dir)" + printf ' write (docs/platform-support.md). The check is not turned off.\n' + fi + return 0 +} diff --git a/tests/shell/test-doctor.sh b/tests/shell/test-doctor.sh index ab55194e..5cd0b328 100755 --- a/tests/shell/test-doctor.sh +++ b/tests/shell/test-doctor.sh @@ -3274,6 +3274,23 @@ case_doctor_parent_operation_warns_with_reconcile_hint() { fi } +# Behavior: a state root another user can write to is reported by Doctor as a failure with the way out, at setup time, +# instead of surfacing as a refusal on the first state-writing pmctl command. +case_doctor_unsafe_state_root_fails_with_remedy() { + local name="doctor-unsafe-state-root-fails-with-remedy" + should_run "$name" || return 0 + th_require_mode_bits "$name" || return 0 + local store="$tmp_root/unsafe-doctor-state" out status=0 + mkdir -p "$store"; chmod 777 "$store" + out="$(PM_DISPATCH_STATE_ROOT="$store" bash "$DOCTOR" --no-color --repo "$REPO_ROOT" 2>&1)" || status=$? + if [[ "$status" -ne 0 && "$out" == *"[FAIL]"*"state root"*"is rejected"* && "$out" == *"install.sh"*"--fix-state-root"* ]]; then + pass "$name" + else + fail "$name" "unsafe state root not reported as a failure with a remedy: status=$status out=$out" + fi +} + +case_doctor_unsafe_state_root_fails_with_remedy case_doctor_all_ok_exits_0 case_doctor_executor_unauthed_fails case_doctor_executor_authed_via_credfile_ok diff --git a/tests/shell/test-install.sh b/tests/shell/test-install.sh index 42eff9ad..321989a9 100755 --- a/tests/shell/test-install.sh +++ b/tests/shell/test-install.sh @@ -4783,6 +4783,32 @@ test_legacy_claude_manifest_migrates_to_product_receipt() { pass "$name" } +# Behavior: install says, when it finishes, that a rejected state root will stop pmctl, repairs it only when asked +# (--fix-state-root), and then leaves an owner-only directory the safety check accepts. The install itself still succeeds. +test_install_reports_and_fixes_rejected_state_root() { + local name="install-reports-and-fixes-rejected-state-root" + should_run "$name" || return 0 + th_require_mode_bits "$name" || return 0 + local base="$tmp_root/$name" unsafe="$tmp_root/$name/unsafe-state" private="$tmp_root/$name/private-state" + local out="$base/out" rc=0 mode + mkdir -p "$unsafe"; chmod 777 "$unsafe" + _install_with_state() { + HOME="$base/home" CODEX_HOME="$base/codex" PM_DISPATCH_INSTALL_ROOT="$base/receipt" PMCTL_BIN_DIR="$base/bin" PM_DISPATCH_STATE_ROOT="$unsafe" PM_DISPATCH_PRIVATE_STATE_ROOT="$private" CLAUDE_CONFIG_TEST_INSTALL_RUNNING=1 bash "$REPO_ROOT/install.sh" --host codex "$@" >"$out" 2>&1 /dev/null || stat -f '%Lp' "$private" 2>/dev/null || true)" + if [[ "$rc" -eq 0 && "$mode" == 700 && "$(grep -c "export PM_DISPATCH_STATE_ROOT=\"$private\"" "$out")" -eq 1 ]]; then + pass "$name" + else + fail "$name" "--fix-state-root did not leave an owner-only state root: rc=$rc mode=$mode out=$(cat "$out")" + fi +} + test_product_receipt_root_override_isolated() { local name="product-receipt-root-override-isolated" should_run "$name" || return 0 @@ -4829,5 +4855,6 @@ test_uninstall_missing_host_write_library_preserves_manifest_teardown test_receipt_partial_host_uninstall_preserves_remaining_owner test_legacy_claude_manifest_migrates_to_product_receipt test_product_receipt_root_override_isolated +test_install_reports_and_fixes_rejected_state_root th_summary From 0c1002e10b69f4f7371946366bb0a6fc23c8d9ff Mon Sep 17 00:00:00 2001 From: Lien Chen Date: Thu, 12 Nov 2026 21:04:19 +0900 Subject: [PATCH 2/8] feat(install): check the state root before changing anything, and let a person cancel The warning came after the install had finished. It now runs before any change: a person at a terminal chooses y (use ~/.pm-dispatch-state), n (install unchanged) or c (cancel, nothing changed); a scripted install still only warns, and --fix-state-root still repairs without asking. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01ESW7P8BvZ5Crn6vi6EA2J7 --- docs/platform-support.md | 2 +- install.sh | 11 +++++++--- runtime/lib/state-root-fix.sh | 40 +++++++++++++++++++++++------------ 3 files changed, 35 insertions(+), 18 deletions(-) diff --git a/docs/platform-support.md b/docs/platform-support.md index 80c97c7e..f86fe3b8 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -387,7 +387,7 @@ things to know: warning. `bash install.sh --fix-state-root` does the steps above for you: it creates `~/.pm-dispatch-state` with an owner-only ACL (an existing directory is only checked), confirms the guard accepts it, and on native Windows sets the user environment variable `PM_DISPATCH_STATE_ROOT`; restart open terminals, Claude Code and Codex afterwards. - Without the flag, an interactive install asks first and a scripted install only warns. + Without the flag, an interactive install asks before changing anything (use it, install unchanged, or cancel) and a scripted install only warns. - **`PM_DISPATCH_STATE_ROOT` (and `XDG_DATA_HOME`) must be written as a POSIX path, `/c/Users/...`, not `C:/Users/...`.** The gate's run directory is derived from the state root and its supervisor refuses a drive-letter run directory. `pmctl gate run` checks the state root before it starts anything diff --git a/install.sh b/install.sh index 62cb1815..f23774c6 100755 --- a/install.sh +++ b/install.sh @@ -17,7 +17,8 @@ # # --fix-state-root: when the pmctl state root is one the safety check rejects (another account can write it), create # ~/.pm-dispatch-state (owner-only) and use it as PM_DISPATCH_STATE_ROOT. Without the flag install only reports the -# problem, and offers the same repair when a person is at the terminal. Never changes an existing directory. +# problem and goes on; a person at the terminal is asked first (y use it, n install unchanged, c cancel), before +# anything is changed. Never changes an existing directory. # # --verify runs all preflight test suites before installing. # Skipped by default; recommended when contributing or after updating. @@ -731,6 +732,12 @@ if [[ "$VERIFY" -eq 1 ]] && [[ "$_SKIP_PREFLIGHT" != "1" ]]; then echo fi +# Before anything is changed: tell the person if the state root the tools will use is one the safety check +# rejects, and let them fix it or stop, rather than finishing an install whose pmctl cannot write its state. +if [[ "$DRY_RUN" -eq 0 ]]; then + state_root_install_preflight "$REPO_ROOT" "$FIX_STATE_ROOT" || { echo "install: cancelled; nothing was changed" >&2; exit 1; } +fi + # Validate every selected host before the Claude product install mutates # anything. Host installers define --dry-run as a read-only ownership and # conflict check, so a user-owned policy fails the whole multi-host operation @@ -772,7 +779,6 @@ if [[ "$_INSTALL_CLAUDE" -eq 0 ]]; then echo "install: product receipt write failed" >&2 exit 3 fi - [[ "$DRY_RUN" -eq 1 ]] || state_root_install_preflight "$REPO_ROOT" "$FIX_STATE_ROOT" echo "Done." [[ "$DRY_RUN" -eq 1 ]] && echo "(no changes made — re-run without --dry-run to apply)" exit 0 @@ -986,7 +992,6 @@ if [[ "$_COPY_FALLBACK_COUNT" -gt 0 && "$DRY_RUN" -eq 0 ]]; then echo " Re-run install.sh after pulling to keep copied files up to date." fi -[[ "$DRY_RUN" -eq 1 ]] || state_root_install_preflight "$REPO_ROOT" "$FIX_STATE_ROOT" echo "Done." if [[ "$DRY_RUN" -eq 1 ]]; then echo "(no changes made — re-run without --dry-run to apply)" diff --git a/runtime/lib/state-root-fix.sh b/runtime/lib/state-root-fix.sh index 63b07d65..4a9508d0 100644 --- a/runtime/lib/state-root-fix.sh +++ b/runtime/lib/state-root-fix.sh @@ -68,26 +68,38 @@ state_root_fix() { } # state_root_install_preflight -# For install.sh, after a real install. Silent when the state root is fine. Otherwise prints what is wrong and -# repairs it when asked (--fix-state-root) or when a person at a terminal answers yes. Always returns 0: the -# install itself succeeded; this only says whether the tools it installed can write their state. +# For install.sh, before it changes anything. Silent when the state root is fine. Otherwise prints what is wrong and +# acts on the answer: --fix-state-root repairs it without asking; a person at a terminal chooses y (use the private +# directory), n (install anyway, unchanged) or c (cancel). A scripted install only warns and goes on. Returns 1 only +# when the person cancels. state_root_install_preflight() { local repo="$1" fix="${2:-0}" reason answer="" reason="$(state_root_unsafe_reason "$repo")" || return 0 - printf '\nWARNING: the pmctl state root is rejected, so pmctl task, dispatch, gate and the other state-writing\n' - printf ' commands will refuse to run until it is changed:\n %s\n' "$reason" - if [[ "$fix" -ne 1 ]]; then - if [[ -t 0 && -t 1 ]]; then - printf 'Create %s (writable only by you) and use it as the state root? [y/N] ' "$(state_root_private_dir)" - read -r answer || answer="" - [[ "$answer" == [yY]* ]] && fix=1 - fi + printf ' +WARNING: the pmctl state root is rejected, so pmctl task, dispatch, gate and the other state-writing +' + printf ' commands will refuse to run until it is changed: + %s +' "$reason" + if [[ "$fix" -ne 1 && -t 0 && -t 1 ]]; then + printf 'Use %s (writable only by you) as the state root? +' "$(state_root_private_dir)" + printf ' y = yes, create it and install n = install without changing it c = cancel the install [y/n/c] ' + read -r answer || answer=c + case "$answer" in + [yY]*) fix=1 ;; + [nN]*) ;; + *) return 1 ;; + esac fi if [[ "$fix" -eq 1 ]]; then - state_root_fix "$repo" || printf 'install: the state root was not changed; see docs/platform-support.md\n' >&2 + state_root_fix "$repo" || printf 'install: the state root was not changed; see docs/platform-support.md +' >&2 else - printf 'Fix: re-run with --fix-state-root to use %s, or set PM_DISPATCH_STATE_ROOT to a directory only you can\n' "$(state_root_private_dir)" - printf ' write (docs/platform-support.md). The check is not turned off.\n' + printf 'Fix: re-run with --fix-state-root to use %s, or set PM_DISPATCH_STATE_ROOT to a directory only you can +' "$(state_root_private_dir)" + printf ' write (docs/platform-support.md). The check is not turned off. +' fi return 0 } From 0a6ccf801b4e2a13738577b335acff81129fb684 Mon Sep 17 00:00:00 2001 From: Lien Chen Date: Fri, 13 Nov 2026 00:31:32 +0900 Subject: [PATCH 3/8] fix(install): protect icacls from MSYS conversion, create the private root exclusively, test repair and consent on every platform Gate remediation: critic-F001 (icacls options rewritten by MSYS), qa-tester-F001/F002 (native-capable repair and y/n/c/existing-destination coverage), security-reviewer-F001 (exclusive creation). Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01ESW7P8BvZ5Crn6vi6EA2J7 --- runtime/lib/state-root-fix.sh | 12 ++++-- tests/shell/test-install.sh | 69 +++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/runtime/lib/state-root-fix.sh b/runtime/lib/state-root-fix.sh index 4a9508d0..ba3887bc 100644 --- a/runtime/lib/state-root-fix.sh +++ b/runtime/lib/state-root-fix.sh @@ -27,6 +27,9 @@ state_root_unsafe_reason() { jq -r '"\(.store_root): \((.safe_root_reasons // []) | join("; "))"' <<<"$status" 2>/dev/null } +# state_root_is_interactive — true when a person can answer a prompt. A function so a test can stand in for a terminal. +state_root_is_interactive() { [[ -t 0 && -t 1 ]]; } + # state_root_private_dir — where the repair puts the state root. state_root_private_dir() { printf '%s\n' "${PM_DISPATCH_PRIVATE_STATE_ROOT:-$HOME/.pm-dispatch-state}"; } @@ -41,12 +44,15 @@ state_root_fix() { printf 'install: %s exists and is not a directory; not touching it\n' "$dir" >&2; return 1 fi if [[ ! -d "$dir" ]]; then - mkdir -p "$dir" || return 1 + # Exclusive creation: mkdir without -p fails if another actor made the leaf (or a link to some directory) after the + # check above, so permissions are only ever applied to a directory this call created. Owner-only from creation. + mkdir -p "$(dirname "$dir")" || return 1 + ( umask 077; mkdir "$dir" ) || return 1 case "${OSTYPE:-}" in msys*|cygwin*) # SIDs for SYSTEM and Administrators, so this also works on a localized Windows. A new directory only: # an existing one is checked, never re-permissioned. - icacls "$(cygpath -w "$dir")" /inheritance:r /grant:r "$USERNAME:(OI)(CI)F" "*S-1-5-18:(OI)(CI)F" "*S-1-5-32-544:(OI)(CI)F" >/dev/null || return 1 ;; + MSYS2_ARG_CONV_EXCL='*' icacls "$(cygpath -w "$dir")" /inheritance:r /grant:r "$USERNAME:(OI)(CI)F" "*S-1-5-18:(OI)(CI)F" "*S-1-5-32-544:(OI)(CI)F" >/dev/null || return 1 ;; *) chmod 700 "$dir" || return 1 ;; esac fi @@ -81,7 +87,7 @@ WARNING: the pmctl state root is rejected, so pmctl task, dispatch, gate and the printf ' commands will refuse to run until it is changed: %s ' "$reason" - if [[ "$fix" -ne 1 && -t 0 && -t 1 ]]; then + if [[ "$fix" -ne 1 ]] && state_root_is_interactive; then printf 'Use %s (writable only by you) as the state root? ' "$(state_root_private_dir)" printf ' y = yes, create it and install n = install without changing it c = cancel the install [y/n/c] ' diff --git a/tests/shell/test-install.sh b/tests/shell/test-install.sh index 321989a9..b1a44bd8 100755 --- a/tests/shell/test-install.sh +++ b/tests/shell/test-install.sh @@ -4809,6 +4809,74 @@ test_install_reports_and_fixes_rejected_state_root() { fi } +# The repair and the y/n/c prompt, runnable on every platform: a stub pmctl reports the starting root as rejected and +# hands the private root to the REAL pmctl guard (so acceptance is the real canonical check, and on native Windows the +# real icacls runs), a stub setx.exe records what would be persisted, and a function stands in for a terminal. +test_state_root_repair_and_consent_contract() { + local name="state-root-repair-and-consent-contract" + should_run "$name" || return 0 + local base="$tmp_root/$name" bin="$tmp_root/$name/bin" repo="$tmp_root/$name/repo" + local private="$tmp_root/$name/private" log="$tmp_root/$name/setx.log" out="$tmp_root/$name/out" rc mode + mkdir -p "$bin" "$repo/cli" + cat >"$repo/cli/pmctl" <<'STUB' +#!/usr/bin/env bash +if [[ "${PM_DISPATCH_STATE_ROOT:-}" == "$STUB_PRIVATE" ]]; then + exec "$STUB_REAL_PMCTL" state status --json --cd "$STUB_REAL_REPO" +fi +printf '{"store_root":"%s","safe_root":false,"safe_root_reasons":["stub: writable by another principal"]}\n' "${PM_DISPATCH_STATE_ROOT:-default}" +STUB + cat >"$bin/setx.exe" <<'STUB' +#!/usr/bin/env bash +printf '%s %s\n' "$1" "$2" >>"$STUB_SETX_LOG" +STUB + chmod +x "$repo/cli/pmctl" "$bin/setx.exe" + _consent() { # [stub-private]; leaves the result in $rc and the text in $out + rc=0; : >"$log" + ( export PATH="$bin:$PATH" HOME="$base/home" PM_DISPATCH_PRIVATE_STATE_ROOT="$private" PM_DISPATCH_STATE_ROOT="$base/unsafe-start" \ + STUB_PRIVATE="${2:-$private}" STUB_REAL_PMCTL="$REPO_ROOT/cli/pmctl" STUB_REAL_REPO="$REPO_ROOT" STUB_SETX_LOG="$log" + # shellcheck source=/dev/null + source "$REPO_ROOT/runtime/lib/state-root-fix.sh" + # shellcheck disable=SC2329 # called by state_root_install_preflight + state_root_is_interactive() { return 0; } + if [[ "$1" == fix ]]; then state_root_install_preflight "$repo" 1; else state_root_install_preflight "$repo" 0 <<<"$1"; fi + ) >"$out" 2>&1 || rc=$? + } + _persisted() { # the exact accepted path reached the user environment (Windows) or the printed profile line (elsewhere) + case "${OSTYPE:-}" in + msys*|cygwin*) [[ "$(cat "$log")" == "PM_DISPATCH_STATE_ROOT $private" ]] ;; + *) grep -qF "export PM_DISPATCH_STATE_ROOT=\"$private\"" "$out" ;; + esac + } + local why="" + # c cancels: exit status 1, nothing created, nothing persisted. + _consent c; [[ "$rc" -eq 1 && ! -e "$private" && ! -s "$log" ]] || why="c: rc=$rc" + # n installs unchanged: success, nothing created, nothing persisted, the rejection stays reported. + _consent n; [[ "$rc" -eq 0 && ! -e "$private" && ! -s "$log" ]] || why="$why n: rc=$rc" + grep -q 'the pmctl state root is rejected' "$out" || why="$why n: no warning" + # y creates the directory, the real guard accepts it, and the exact path is persisted. + _consent y; [[ "$rc" -eq 0 && -d "$private" ]] && _persisted || why="$why y: rc=$rc out=$(cat "$out")" + case "${OSTYPE:-}" in msys*|cygwin*) ;; *) + mode="$(stat -c '%a' "$private" 2>/dev/null || stat -f '%Lp' "$private" 2>/dev/null || true)" + [[ "$mode" == 700 ]] || why="$why y: mode=$mode" ;; + esac + rm -rf "${private:?}" + # An existing directory the guard does not accept is left exactly as it was and is never persisted. + mkdir -p "$private"; printf 'keep\n' >"$private/data" + case "${OSTYPE:-}" in msys*|cygwin*) ;; *) chmod 755 "$private"; mode="$(stat -c '%a' "$private" 2>/dev/null || stat -f '%Lp' "$private")" ;; esac + _consent fix "$base/not-the-private-root" + if [[ "$(cat "$private/data")" != keep || -s "$log" ]] || grep -q 'export PM_DISPATCH_STATE_ROOT' "$out"; then + why="$why existing: content or persistence changed" + fi + case "${OSTYPE:-}" in msys*|cygwin*) ;; *) [[ "$(stat -c '%a' "$private" 2>/dev/null || stat -f '%Lp' "$private")" == "$mode" ]] || why="$why existing: mode changed" ;; esac + rm -rf "${private:?}" + # A path that is a file is refused untouched. + printf 'keep\n' >"$private" + _consent fix + if [[ "$(cat "$private")" != keep || -s "$log" ]]; then why="$why file: changed"; fi + rm -f "$private" + if [[ -z "$why" ]]; then pass "$name"; else fail "$name" "$why"; fi +} + test_product_receipt_root_override_isolated() { local name="product-receipt-root-override-isolated" should_run "$name" || return 0 @@ -4856,5 +4924,6 @@ test_receipt_partial_host_uninstall_preserves_remaining_owner test_legacy_claude_manifest_migrates_to_product_receipt test_product_receipt_root_override_isolated test_install_reports_and_fixes_rejected_state_root +test_state_root_repair_and_consent_contract th_summary From eadb21acaaafa190cba365f9361fe7c424934848 Mon Sep 17 00:00:00 2001 From: Lien Chen Date: Fri, 13 Nov 2026 00:47:55 +0900 Subject: [PATCH 4/8] test(doctor): run the unsafe state root case on native Windows with a real ACL grant Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01ESW7P8BvZ5Crn6vi6EA2J7 --- tests/shell/test-doctor.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/shell/test-doctor.sh b/tests/shell/test-doctor.sh index 5cd0b328..7879d82f 100755 --- a/tests/shell/test-doctor.sh +++ b/tests/shell/test-doctor.sh @@ -3279,9 +3279,13 @@ case_doctor_parent_operation_warns_with_reconcile_hint() { case_doctor_unsafe_state_root_fails_with_remedy() { local name="doctor-unsafe-state-root-fails-with-remedy" should_run "$name" || return 0 - th_require_mode_bits "$name" || return 0 local store="$tmp_root/unsafe-doctor-state" out status=0 - mkdir -p "$store"; chmod 777 "$store" + mkdir -p "$store" + case "${OSTYPE:-}" in + # Native Windows has no mode bits: a real ACL grant to Everyone is the unsafe state the guard rejects. + msys*|cygwin*) MSYS2_ARG_CONV_EXCL='*' icacls "$(cygpath -w "$store")" /grant '*S-1-1-0:(OI)(CI)M' >/dev/null ;; + *) chmod 777 "$store" ;; + esac out="$(PM_DISPATCH_STATE_ROOT="$store" bash "$DOCTOR" --no-color --repo "$REPO_ROOT" 2>&1)" || status=$? if [[ "$status" -ne 0 && "$out" == *"[FAIL]"*"state root"*"is rejected"* && "$out" == *"install.sh"*"--fix-state-root"* ]]; then pass "$name" From 55034212d1567ab9d7192e4be2c65c69a8c93098 Mon Sep 17 00:00:00 2001 From: Lien Chen Date: Fri, 13 Nov 2026 01:04:36 +0900 Subject: [PATCH 5/8] test(install): give each state-root repair stage its own path so no stage needs to delete an owner-only directory Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01ESW7P8BvZ5Crn6vi6EA2J7 --- tests/shell/test-install.sh | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/shell/test-install.sh b/tests/shell/test-install.sh index b1a44bd8..edba11f3 100755 --- a/tests/shell/test-install.sh +++ b/tests/shell/test-install.sh @@ -4859,7 +4859,9 @@ STUB mode="$(stat -c '%a' "$private" 2>/dev/null || stat -f '%Lp' "$private" 2>/dev/null || true)" [[ "$mode" == 700 ]] || why="$why y: mode=$mode" ;; esac - rm -rf "${private:?}" + # Each stage uses its own path: the private root's owner-only ACL also excludes other accounts (a sandboxed runner + # included), so no stage may depend on removing the previous one. + private="$base/private-existing" # An existing directory the guard does not accept is left exactly as it was and is never persisted. mkdir -p "$private"; printf 'keep\n' >"$private/data" case "${OSTYPE:-}" in msys*|cygwin*) ;; *) chmod 755 "$private"; mode="$(stat -c '%a' "$private" 2>/dev/null || stat -f '%Lp' "$private")" ;; esac @@ -4868,12 +4870,11 @@ STUB why="$why existing: content or persistence changed" fi case "${OSTYPE:-}" in msys*|cygwin*) ;; *) [[ "$(stat -c '%a' "$private" 2>/dev/null || stat -f '%Lp' "$private")" == "$mode" ]] || why="$why existing: mode changed" ;; esac - rm -rf "${private:?}" + private="$base/private-file" # A path that is a file is refused untouched. printf 'keep\n' >"$private" _consent fix if [[ "$(cat "$private")" != keep || -s "$log" ]]; then why="$why file: changed"; fi - rm -f "$private" if [[ -z "$why" ]]; then pass "$name"; else fail "$name" "$why"; fi } From 4c6675bf543b257af006396964ded34858b1c9f9 Mon Sep 17 00:00:00 2001 From: Lien Chen Date: Fri, 13 Nov 2026 01:20:10 +0900 Subject: [PATCH 6/8] fix(install): grant the private state root to the SID of the running account, not the profile name Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01ESW7P8BvZ5Crn6vi6EA2J7 --- runtime/lib/state-root-fix.sh | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/runtime/lib/state-root-fix.sh b/runtime/lib/state-root-fix.sh index ba3887bc..058a1df3 100644 --- a/runtime/lib/state-root-fix.sh +++ b/runtime/lib/state-root-fix.sh @@ -38,7 +38,7 @@ state_root_private_dir() { printf '%s\n' "${PM_DISPATCH_PRIVATE_STATE_ROOT:-$HOM # it as the user's PM_DISPATCH_STATE_ROOT. Elsewhere it prints the line to add to a shell profile. Returns 0 on # success, 1 when the directory could not be made safe (nothing is persisted then). state_root_fix() { - local repo="$1" dir check + local repo="$1" dir check me dir="$(state_root_private_dir)" if [[ -e "$dir" && ! -d "$dir" ]]; then printf 'install: %s exists and is not a directory; not touching it\n' "$dir" >&2; return 1 @@ -52,7 +52,12 @@ state_root_fix() { msys*|cygwin*) # SIDs for SYSTEM and Administrators, so this also works on a localized Windows. A new directory only: # an existing one is checked, never re-permissioned. - MSYS2_ARG_CONV_EXCL='*' icacls "$(cygpath -w "$dir")" /inheritance:r /grant:r "$USERNAME:(OI)(CI)F" "*S-1-5-18:(OI)(CI)F" "*S-1-5-32-544:(OI)(CI)F" >/dev/null || return 1 ;; + # The grant goes to the SID of the account this process actually runs as (whoami /user), not the $USERNAME + # profile name: a sandboxed or service session can run as a different account than its profile's, and the + # canonical guard rejects a grant to anyone but the owner. + me="$(MSYS2_ARG_CONV_EXCL='*' "$(cygpath -u "${SYSTEMROOT:-C:\\Windows}")/System32/whoami.exe" /user /fo csv /nh 2>/dev/null | cut -d, -f2 | tr -d '"\r')" + [[ "$me" == S-1-* ]] && me="*$me" || me="$USERNAME" + MSYS2_ARG_CONV_EXCL='*' icacls "$(cygpath -w "$dir")" /inheritance:r /grant:r "$me:(OI)(CI)F" "*S-1-5-18:(OI)(CI)F" "*S-1-5-32-544:(OI)(CI)F" >/dev/null || return 1 ;; *) chmod 700 "$dir" || return 1 ;; esac fi From bede8f087c74702d5bf4f08d4ff77f0387a1d78d Mon Sep 17 00:00:00 2001 From: Lien Chen Date: Sat, 14 Nov 2026 12:01:42 +0900 Subject: [PATCH 7/8] test(doctor): give the detached-run cases a private state root so Doctor's new check accepts them on native Windows A state root made with mkdir -p under the temp root inherits a write grant for another principal on native Windows (e.g. the Codex sandbox group), which Doctor now reports as a failure. The stale and indeterminate detached-run cases create their state root with an owner-only helper instead. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01ESW7P9zqMvS4GeAcn3S3Jw --- tests/shell/test-doctor.sh | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/shell/test-doctor.sh b/tests/shell/test-doctor.sh index 7879d82f..e8f8f6a8 100755 --- a/tests/shell/test-doctor.sh +++ b/tests/shell/test-doctor.sh @@ -66,6 +66,21 @@ _td_needs_chmod_x() { return 1 } +# Creates a state root only its owner can write. Doctor now rejects a state root another principal can write, and on +# native Windows a directory made under the temp root inherits such a grant (e.g. the Codex sandbox group), so a case +# that needs a state root which Doctor accepts must not rely on mkdir -p alone. +_td_private_state_root() { + local dir="$1" me + ( umask 077; mkdir -p "$dir" ) || return 1 + case "${OSTYPE:-}" in + msys*|cygwin*) + me="$(MSYS2_ARG_CONV_EXCL='*' "$(cygpath -u "${SYSTEMROOT:-C:\\Windows}")/System32/whoami.exe" /user /fo csv /nh 2>/dev/null | cut -d, -f2 | tr -d '"\r')" + [[ "$me" == S-1-* ]] && me="*$me" || me="$USERNAME" + MSYS2_ARG_CONV_EXCL='*' icacls "$(cygpath -w "$dir")" /inheritance:r /grant:r "$me:(OI)(CI)F" "*S-1-5-18:(OI)(CI)F" "*S-1-5-32-544:(OI)(CI)F" >/dev/null || return 1 ;; + *) chmod 700 "$dir" || return 1 ;; + esac +} + add_dispatch_allowlist() { # Mirrors dispatch_allowlist_entries() using an explicit home arg for path stripping. local home_dir="$1" @@ -1036,6 +1051,7 @@ case_doctor_detached_runs_stale_warns() { write_manifest "$home" path="$(make_stub_bin "$tmp_root/bin-detached-stale" claude codex)" state_root="$tmp_root/state-detached-stale" + _td_private_state_root "$state_root" || { fail "$name" "could not create a private state root"; return 0; } run_id="run-20260719T000000Z-dr0001" art_dir="$(PM_DISPATCH_STATE_ROOT="$state_root" dispatch_test_run_trace_dir "$REPO_ROOT" "$run_id")" mkdir -p "$art_dir" @@ -1090,6 +1106,7 @@ case_doctor_detached_runs_indeterminate_no_convergence_promise() { write_manifest "$home" path="$(make_stub_bin "$tmp_root/bin-detached-indet" claude codex)" state_root="$tmp_root/state-detached-indet" + _td_private_state_root "$state_root" || { fail "$name" "could not create a private state root"; return 0; } run_id="run-20260719T000000Z-dr0002" decoy="$(dispatch_test_isolated_decoy)" || { From 1df34db79606772fd3901d340a68ce345ca21a30 Mon Sep 17 00:00:00 2001 From: Lien Chen Date: Sat, 14 Nov 2026 14:17:50 +0900 Subject: [PATCH 8/8] test(install): assert a pre-existing state root keeps its ACL on native Windows The repair promises to inspect an existing directory and never change its permissions. The Windows branch of the existing-directory case checked contents and persistence only, so moving the icacls call outside the creation branch would still pass. It now compares the directory's real ACL before and after the repair. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01ESW7P9zqMvS4GeAcn3S3Jw --- tests/shell/test-install.sh | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/shell/test-install.sh b/tests/shell/test-install.sh index edba11f3..822db9a2 100755 --- a/tests/shell/test-install.sh +++ b/tests/shell/test-install.sh @@ -4864,12 +4864,21 @@ STUB private="$base/private-existing" # An existing directory the guard does not accept is left exactly as it was and is never persisted. mkdir -p "$private"; printf 'keep\n' >"$private/data" - case "${OSTYPE:-}" in msys*|cygwin*) ;; *) chmod 755 "$private"; mode="$(stat -c '%a' "$private" 2>/dev/null || stat -f '%Lp' "$private")" ;; esac + local acl_before="" acl_after="" + _acl() { MSYS2_ARG_CONV_EXCL='*' icacls "$(cygpath -w "$1")" 2>&1; } # the DACL itself, not a count of icacls calls + case "${OSTYPE:-}" in + msys*|cygwin*) acl_before="$(_acl "$private")"; [[ "$acl_before" == *"$(basename "$private")"* ]] || why="$why existing: ACL not readable" ;; + *) chmod 755 "$private"; mode="$(stat -c '%a' "$private" 2>/dev/null || stat -f '%Lp' "$private")" ;; + esac _consent fix "$base/not-the-private-root" if [[ "$(cat "$private/data")" != keep || -s "$log" ]] || grep -q 'export PM_DISPATCH_STATE_ROOT' "$out"; then why="$why existing: content or persistence changed" fi - case "${OSTYPE:-}" in msys*|cygwin*) ;; *) [[ "$(stat -c '%a' "$private" 2>/dev/null || stat -f '%Lp' "$private")" == "$mode" ]] || why="$why existing: mode changed" ;; esac + case "${OSTYPE:-}" in + # Windows has no mode bits: the pre-existing directory's ACL must be byte-for-byte what it was before the repair. + msys*|cygwin*) acl_after="$(_acl "$private")"; [[ "$acl_after" == "$acl_before" ]] || why="$why existing: ACL changed" ;; + *) [[ "$(stat -c '%a' "$private" 2>/dev/null || stat -f '%Lp' "$private")" == "$mode" ]] || why="$why existing: mode changed" ;; + esac private="$base/private-file" # A path that is a file is refused untouched. printf 'keep\n' >"$private"