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..f86fe3b8 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 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 7acd023d..f23774c6 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,11 @@ # 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 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. # @@ -55,6 +60,7 @@ unset _jq_lf_dir DRY_RUN=0 VERIFY=0 +FIX_STATE_ROOT=0 PROFILE="" ENABLED_HOSTS=() SELECTED_HOSTS=(claude) @@ -63,6 +69,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 +139,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" @@ -723,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 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..058a1df3 --- /dev/null +++ b/runtime/lib/state-root-fix.sh @@ -0,0 +1,116 @@ +#!/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_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}"; } + +# 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 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 + fi + if [[ ! -d "$dir" ]]; then + # 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. + # 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 + 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, 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 ' +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 ]] && 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] ' + 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 +' >&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 +' "$(state_root_private_dir)" + printf ' write (docs/platform-support.md). The check is not turned off. +' + fi + return 0 +} diff --git a/tests/shell/test-doctor.sh b/tests/shell/test-doctor.sh index ab55194e..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)" || { @@ -3274,6 +3291,27 @@ 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 + local store="$tmp_root/unsafe-doctor-state" out status=0 + 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" + 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..822db9a2 100755 --- a/tests/shell/test-install.sh +++ b/tests/shell/test-install.sh @@ -4783,6 +4783,110 @@ 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 +} + +# 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 + # 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" + 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 + # 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" + _consent fix + if [[ "$(cat "$private")" != keep || -s "$log" ]]; then why="$why file: changed"; fi + 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 @@ -4829,5 +4933,7 @@ 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 +test_state_root_repair_and_consent_contract th_summary