diff --git a/VERSION b/VERSION index df5db66..b056f41 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.23 +0.0.24 diff --git a/docs/reference/cli.md b/docs/reference/cli.md index de64710..9b8bd79 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -13,21 +13,52 @@ opencode-permissions-kit config projects add /var/www/vhosts/new-project opencode-permissions-kit update --binary opencode-permissions-kit upgrade-opencode # just the opencode binary opencode-permissions-kit ddev-hosts-add # in a ddev project dir +opencode-permissions-kit handover me .gotmp # mixed-owner tree -> yours again opencode-permissions-kit uninstall opencode-permissions-kit help # commands + arguments overview ``` Everything after the subcommand goes to the underlying script unchanged, -so all flags below work with both forms. `config` and `update` elevate via -sudo automatically; `status` needs no sudo; `uninstall` runs as your user -and asks for sudo itself; `ddev-hosts-*` run as your user (they drive -Windows-side elevation through ddev itself). +so all flags below work with both forms. `config`, `update` and `handover` +elevate via sudo automatically; `status` needs no sudo; `uninstall` runs as +your user and asks for sudo itself; `ddev-hosts-*` run as your user (they +drive Windows-side elevation through ddev itself). The command is a symlink (`/usr/local/bin/opencode-permissions-kit`) into the kit library — deployed since kit 0.0.14. On older installs, run [update](../how-to/update.md) once to get it. The direct script calls below keep working everywhere. +## handover + +Switch file ownership between the two kit users — you and the agent: + +```bash +# You and the agent both ran builds in the same checkout; ddev's .gotmp +# cache now contains files of both users and every build complains +# ("chmod ... Operation not permitted"). Make the whole tree yours again: +cd /var/www/vhosts/ddev +opencode-permissions-kit handover me .gotmp + +# Same idea the other way — give a folder to the agent user: +opencode-permissions-kit handover opencode /var/www/vhosts/some-project/ + +# Not sure yet? Show what would happen, without sudo: +opencode-permissions-kit handover me .gotmp --dry-run +``` + +`me` and `opencode` resolve to your default user and the agent user from +the kit's install configuration — no usernames to remember, no root shell. +Plain `chown -R` would need both. + +The change is recursive and only flips the **owner** — the group stays the +kit's sharing group and group-write access is re-applied, so both sides +keep their group access to the tree (the same semantics as the ddev +handovers, see [ddev integration](../concepts/ddev-integration.md)). +System roots (`/`, `/usr`, `/var`, ...) and whole home directories are +refused — hand over trees, not systems. The command elevates via sudo +itself; `--dry-run` validates and prints the plan without sudo. + ## ddev-hosts-add / ddev-hosts-check Windows hosts bridge (WSL2): ddev runs as `opencode` and cannot manage diff --git a/files/opencode-permissions-kit-lib/kit b/files/opencode-permissions-kit-lib/kit index d7ee29f..bc148f0 100755 --- a/files/opencode-permissions-kit-lib/kit +++ b/files/opencode-permissions-kit-lib/kit @@ -70,7 +70,12 @@ Commands: that project's missing hostnames, a hostname adds exactly that one ddev-hosts-check [dir] List this project's ddev hostnames missing - from the Windows hosts file + from the Windows hosts file + handover ... Recursively hand file ownership to you (me) + or the agent user (opencode) — the sharing + group stays, so both sides keep access. For + mixed-owner trees, e.g. after builds by + both users. --dry-run shows the plan uninstall [--yes] [--dry-run] Remove the kit (runs as your user) help Show this help @@ -154,6 +159,88 @@ case "$cmd" in ;; esac ;; + handover) + # Generic ownership switch between the two kit users, for + # mixed-owner trees (e.g. a checkout where BOTH the developer and + # the agent ran builds, like ddev's .gotmp cache): recursive chown + # to + the kit's SHARING group, then g+w — the same + # semantics as the ddev handovers (ddev-handover.sh), applied to + # any path. The group never changes, so both sides keep group + # access; only the owner flips. + # handover me ... -> DEFAULT_USER:OPENCODE_GROUP + # handover opencode ... -> OPENCODE_USER:OPENCODE_GROUP + # --dry-run prints the plan and changes nothing (no sudo needed). + # System roots and whole home directories are refused: hand over + # trees, not systems. Validation runs BEFORE the sudo hop, so + # argument errors and --dry-run work unprivileged. + _ho_err() { + ui_error "$1" + echo " usage: opencode-permissions-kit handover ... [--dry-run]" >&2 + exit 1 + } + [ "$#" -ge 2 ] || _ho_err "handover: need a target (me or opencode) and at least one path" + _ho_target="$1" + shift + _ho_dry=0 + for _ho_a in "$@"; do + [ "$_ho_a" = "--dry-run" ] && _ho_dry=1 + done + # Usage errors first: a wrong target must not be masked by a + # missing install.conf (e.g. dev checkouts without an install). + case "$_ho_target" in + me|opencode) ;; + *) _ho_err "handover: unknown target '$_ho_target' (me or opencode)" ;; + esac + [ -f "$INSTALL_CONF" ] || _ho_err "handover: $INSTALL_CONF not found — is the kit installed?" + _ho_getconf() { sed -n "s/^$1=//p" "$INSTALL_CONF" 2>/dev/null | tail -1; } + _ho_group=$(_ho_getconf OPENCODE_GROUP) + [ -n "$_ho_group" ] || _ho_err "handover: install.conf has no OPENCODE_GROUP" + case "$_ho_target" in + me) _ho_user=$(_ho_getconf DEFAULT_USER) ;; + opencode) _ho_user=$(_ho_getconf OPENCODE_USER) ;; + esac + [ -n "$_ho_user" ] || _ho_err "handover: install.conf has no user for '$_ho_target'" + id -u "$_ho_user" >/dev/null 2>&1 || _ho_err "handover: user '$_ho_user' does not exist" + for _ho_a in "$@"; do + [ "$_ho_a" = "--dry-run" ] && continue + _ho_np=$(realpath -m "$_ho_a" 2>/dev/null || printf '%s' "$_ho_a") + case "$_ho_np" in + /|/bin|/boot|/dev|/etc|/home|/lib|/lib32|/lib64|/libx32|/media|/mnt|/opt|/proc|/root|/run|/sbin|/srv|/sys|/tmp|/usr|/var) + _ho_err "handover: refusing system path '$_ho_np' — hand over subpaths, not system roots" ;; + esac + case "$_ho_np" in + /home/*) [ "$(dirname "$_ho_np")" = "/home" ] && _ho_err "handover: refusing whole home directory '$_ho_np' — hand over subpaths" ;; + esac + [ -e "$_ho_np" ] || _ho_err "handover: no such path: $_ho_a" + done + if [ "$_ho_dry" = 1 ]; then + for _ho_a in "$@"; do + [ "$_ho_a" = "--dry-run" ] && continue + echo " would hand over: $_ho_a -> $_ho_user:$_ho_group (recursive, g+w)" + done + exit 0 + fi + if [ "$(id -u)" -ne 0 ]; then + # Loop guard: sudo must actually elevate (fake/broken sudo + # would re-enter this branch forever). + [ "${OPK_HANDOVER_ROOT:-}" = "1" ] && _ho_err "handover: sudo did not elevate — giving up" + exec sudo env OPK_INSTALL_CONF="$INSTALL_CONF" OPK_HANDOVER_ROOT=1 \ + sh "$LIBDIR/kit" handover "$_ho_target" "$@" + fi + _ho_rc=0 + ui_info "handing over to $_ho_user:$_ho_group (recursive, g+w) ..." + for _ho_a in "$@"; do + [ "$_ho_a" = "--dry-run" ] && continue + if chown -R "$_ho_user:$_ho_group" "$_ho_a" 2>/dev/null && chmod -R g+w "$_ho_a" 2>/dev/null; then + echo " handover: $_ho_a -> $_ho_user:$_ho_group" + else + ui_error "handover failed: $_ho_a" + _ho_rc=1 + fi + done + [ "$_ho_rc" = 0 ] && ui_success "handover complete" + exit "$_ho_rc" + ;; help|--help|-h) usage ;; diff --git a/tests/test-kit-cli.sh b/tests/test-kit-cli.sh index 0e4782c..be64ed5 100755 --- a/tests/test-kit-cli.sh +++ b/tests/test-kit-cli.sh @@ -36,7 +36,10 @@ LIB="$WORK/lib" mkdir -p "$LIB" # install.conf carries the deployed VERSION stamp (a standalone VERSION file # is NOT deployed to the library — regression guard for the v0.0.0 display). -printf 'DEFAULT_USER=dev\nVERSION=9.9.9\n' > "$WORK/install.conf" +# Users must EXIST for the handover tests (the command validates with id); +# the current user/group double as the fake developer and opencode user. +printf 'DEFAULT_USER=%s\nOPENCODE_USER=%s\nOPENCODE_GROUP=%s\nVERSION=9.9.9\n' \ + "$(id -un)" "$(id -un)" "$(id -gn)" > "$WORK/install.conf" for s in status config update uninstall; do cat > "$LIB/$s.sh" < "$WORK/ho-tree/sub/file" + +# no / too few arguments -> usage error +if run_kit handover >/dev/null 2>&1; then + echo " ${RED}FAIL${NC} handover without args exits non-zero"; failures=$((failures + 1)) +else + echo " ${GREEN}PASS${NC} handover without args exits non-zero"; passed=$((passed + 1)) +fi +if run_kit handover me >/dev/null 2>&1; then + echo " ${RED}FAIL${NC} handover without a path exits non-zero"; failures=$((failures + 1)) +else + echo " ${GREEN}PASS${NC} handover without a path exits non-zero"; passed=$((passed + 1)) +fi + +# unknown target -> usage error naming the choices (hermetic: explicit +# install.conf — CI runners have no /etc/opencode-permissions-kit) +errout="$(OPK_INSTALL_CONF="$WORK/install.conf" "$BIN/opencode-permissions-kit" handover nobody "$WORK/ho-tree" 2>&1 >/dev/null || true)" +case "$errout" in + *"me or opencode"*) echo " ${GREEN}PASS${NC} handover rejects unknown target"; passed=$((passed + 1)) ;; + *) echo " ${RED}FAIL${NC} handover rejects unknown target (got: $errout)"; failures=$((failures + 1)) ;; +esac + +# nonexistent path -> error +if run_kit handover me "$WORK/does-not-exist" >/dev/null 2>&1; then + echo " ${RED}FAIL${NC} handover rejects nonexistent paths"; failures=$((failures + 1)) +else + echo " ${GREEN}PASS${NC} handover rejects nonexistent paths"; passed=$((passed + 1)) +fi + +# system roots and whole home directories are refused +for _bad in / /usr /etc /var "/home/$(id -un)"; do + errout="$(OPK_INSTALL_CONF="$WORK/install.conf" "$BIN/opencode-permissions-kit" handover me "$_bad" 2>&1 >/dev/null || true)" + case "$errout" in + *"refusing"*) echo " ${GREEN}PASS${NC} handover refuses $_bad"; passed=$((passed + 1)) ;; + *) echo " ${RED}FAIL${NC} handover refuses $_bad (got: $errout)"; failures=$((failures + 1)) ;; + esac +done + +# --dry-run: plan only, no changes, no sudo, exit 0 +rm -f "$WORK/sudo-marker" +out="$(run_kit handover me "$WORK/ho-tree" --dry-run)" +assert "handover --dry-run exits 0" "0" "$?" +case "$out" in + *"would hand over: $WORK/ho-tree -> $(id -un):$(id -gn)"*) + echo " ${GREEN}PASS${NC} handover --dry-run prints the plan (user:group)"; passed=$((passed + 1)) ;; + *) echo " ${RED}FAIL${NC} handover --dry-run prints the plan (got: $out)"; failures=$((failures + 1)) ;; +esac +if [ -f "$WORK/sudo-marker" ]; then + echo " ${RED}FAIL${NC} handover --dry-run needs no sudo"; failures=$((failures + 1)) +else + echo " ${GREEN}PASS${NC} handover --dry-run needs no sudo"; passed=$((passed + 1)) +fi + +# real run (no --dry-run): elevates via sudo. With the FAKE sudo it cannot +# elevate — the loop guard must stop the re-entry instead of recursing. +rm -f "$WORK/sudo-marker" +if [ "$(id -u)" -ne 0 ]; then + errout="$(OPK_INSTALL_CONF="$WORK/install.conf" "$BIN/opencode-permissions-kit" handover me "$WORK/ho-tree" 2>&1 >/dev/null || true)" + if [ -f "$WORK/sudo-marker" ]; then + echo " ${GREEN}PASS${NC} handover elevates via sudo"; passed=$((passed + 1)) + else + echo " ${RED}FAIL${NC} handover elevates via sudo"; failures=$((failures + 1)) + fi + case "$errout" in + *"did not elevate"*) echo " ${GREEN}PASS${NC} handover loop guard stops non-elevating sudo"; passed=$((passed + 1)) ;; + *) echo " ${RED}FAIL${NC} handover loop guard stops non-elevating sudo (got: $errout)"; failures=$((failures + 1)) ;; + esac +else + # test env is root: the real chown runs; the tree must end up owned by + # the conf user with group write access. + out="$(run_kit handover opencode "$WORK/ho-tree")" + _own=$(stat -c '%U:%G' "$WORK/ho-tree/sub/file") + assert "handover chowns recursively (root env)" "$(id -un):$(id -gn)" "$_own" + case "$out" in + *"handover: $WORK/ho-tree"*) echo " ${GREEN}PASS${NC} handover reports each path (root env)"; passed=$((passed + 1)) ;; + *) echo " ${RED}FAIL${NC} handover reports each path (root env, got: $out)"; failures=$((failures + 1)) ;; + esac +fi + # List drift guard: install.sh's fetch_kit() list and update.sh's KIT_FILES # must carry the same file set — a missing entry means streamed installs # fetch an incomplete kit and crash at deploy time (set -e).