Skip to content
Open
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <run-id>` and a `<run-id>.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.

Expand Down
5 changes: 5 additions & 0 deletions docs/architecture/script-variable-consumers.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions docs/platform-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 16 additions & 1 deletion install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>/bash-guard.sh, manifest-driven via needs_bash_guard).
Expand All @@ -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.
#
Expand Down Expand Up @@ -55,6 +60,7 @@ unset _jq_lf_dir

DRY_RUN=0
VERIFY=0
FIX_STATE_ROOT=0
PROFILE=""
ENABLED_HOSTS=()
SELECTED_HOSTS=(claude)
Expand All @@ -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; }
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions runtime/bin/doctor.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -924,6 +938,7 @@ main() {
check_memory_dir
check_frontmatter_lint
check_detached_runs
check_state_root
check_parent_operations
check_usage_tracker_path

Expand Down
116 changes: 116 additions & 0 deletions runtime/lib/state-root-fix.sh
Original file line number Diff line number Diff line change
@@ -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 <repo-root>
# Prints "<store root>: <reasons>" 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 <repo-root>
# 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 <repo-root> <fix: 1|0>
# 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
}
Loading
Loading