From 017f6e17520324db4f2d615f186afe2fe97c8bbb Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 11 May 2026 13:07:50 +0200 Subject: [PATCH 01/22] fix sudo handling and pkgx-under-/root reachability Restore the privilege drop when running as root via sudo (so pkgx caches stay user-owned), but only when the resolved pkgx binary is actually reachable from $SUDO_USER. Falls back to running pkgx as root rather than crashing the inner sudo with "Permission denied" when pkgx lives under /root/.pkgx/ (pkgxdev/pkgm#68). - Restore drop-privilege behaviour for `sudo pkgm` (fixes the regression flagged by jhheider on #83). - Resolve an alternative pkgx under $SUDO_USER's home / /usr/local when the current path is unreachable to $SUDO_USER. - Override HOME so pkgx caches under the invoking user's tree. - Stop mutating `args` so the args[0] lookup at line 341 keeps working. - Avoid the `Deno.env.get("USER")!` non-null assertion crash. - Call install_prefix() once (it has filesystem side effects). - Keep the visible log surface unchanged: only the pre-existing "installing as root" warning fires by default; the new "pkgx unreachable" diagnostic is gated behind PKGM_DEBUG. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkgm.ts | 94 +++++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 82 insertions(+), 12 deletions(-) diff --git a/pkgm.ts b/pkgm.ts index f3f08c3..53dc13f 100755 --- a/pkgm.ts +++ b/pkgm.ts @@ -297,25 +297,43 @@ async function query_pkgx( set("PKGX_DIST_URL"); set("XDG_DATA_HOME"); - const needs_sudo_backwards = install_prefix().string == "/usr/local"; - let cmd = needs_sudo_backwards ? "/usr/bin/sudo" : pkgx; - if (needs_sudo_backwards) { - if (!Deno.env.get("SUDO_USER")) { - if (Deno.uid() == 0) { + const isRoot = Deno.uid() == 0; + const sudoUser = Deno.env.get("SUDO_USER"); + const prefix = install_prefix().string; + const isSystemPrefix = prefix == "/usr/local"; + + let cmd = pkgx; + let cmd_args = args; + + if (isSystemPrefix) { + if (isRoot && sudoUser) { + // Drop privileges so pkgx writes its cache as the invoking user, not root. + // But only if pkgx is reachable from sudoUser — otherwise the inner sudo + // aborts with "unable to execute …: Permission denied" (pkgxdev/pkgm#68). + const reachable = pkgx_reachable_as(pkgx, sudoUser); + if (reachable) { + cmd = "/usr/bin/sudo"; + cmd_args = ["-u", sudoUser, reachable, ...args]; + // Override HOME, or pkgx will cache back under /root/ where sudoUser + // can't reach it on the next invocation. + const home = user_home(sudoUser); + if (home) env.HOME = home; + } else if (Deno.env.get("PKGM_DEBUG")) { console.error( - "%cwarning", - "color:yellow", - "installing as root; installing via `sudo` is preferred", + `pkgm: \`pkgx\` at ${pkgx} is not reachable as ${sudoUser}; running it as root`, ); } - cmd = pkgx; - } else { - args.unshift("-u", Deno.env.get("SUDO_USER")!, pkgx); + } else if (isRoot) { + console.error( + "%cwarning", + "color:yellow", + "installing as root; installing via `sudo` is preferred", + ); } } const proc = new Deno.Command(cmd, { - args: [...args, "--json=v1"], + args: [...cmd_args, "--json=v1"], stdout: "piped", env, clearEnv: true, @@ -766,6 +784,58 @@ function install_prefix() { } } +function user_home(user: string): string | undefined { + // getent is the portable lookup on Linux; on macOS getent is absent but the + // /root/.pkgx scenario this guards against doesn't arise there in practice. + try { + const out = new Deno.Command("/usr/bin/getent", { + args: ["passwd", user], + }).outputSync(); + if (!out.success) return undefined; + const fields = new TextDecoder().decode(out.stdout).trim().split(":"); + return fields[5] || undefined; + } catch { + return undefined; + } +} + +function pkgx_reachable_as(current: string, user: string): string | undefined { + if (reachable_as(current, user)) return current; + + const home = user_home(user); + if (home) { + // Versioned pkgx.sh layout: ~/.pkgx/pkgx.sh/v/bin/pkgx — pick the highest. + const root = join(home, ".pkgx/pkgx.sh"); + if (existsSync(root)) { + let best: { v: SemVer; path: string } | undefined; + for (const entry of Deno.readDirSync(root)) { + if (!entry.isDirectory || !entry.name.startsWith("v")) continue; + try { + const v = new SemVer(entry.name.slice(1)); + const path = join(root, entry.name, "bin/pkgx"); + if (!existsSync(path)) continue; + if (!best || v.gt(best.v)) best = { v, path }; + } catch { /* skip malformed version dir */ } + } + if (best) return best.path; + } + const local = join(home, ".local/bin/pkgx"); + if (existsSync(local)) return local; + } + if (existsSync("/usr/local/bin/pkgx")) return "/usr/local/bin/pkgx"; + return undefined; +} + +function reachable_as(p: string, user: string): boolean { + // Conservative heuristic: private home dirs are typically mode 700, so a + // path under another user's home is unreachable. System paths and the + // user's own home are assumed reachable. + if (p.startsWith("/root/")) return user === "root"; + const m = p.match(/^\/home\/([^/]+)\//); + if (m) return m[1] === user; + return true; +} + function dev_stub_text(selfpath: string, bin_prefix: string, name: string) { if (selfpath.startsWith("/usr/local") && selfpath != "/usr/local/bin/dev") { return ` From 3d8f6a1f2f129c643e5ad8bb95d8234fc8fde701 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 11 May 2026 19:25:29 +0200 Subject: [PATCH 02/22] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- pkgm.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkgm.ts b/pkgm.ts index 53dc13f..28509c3 100755 --- a/pkgm.ts +++ b/pkgm.ts @@ -788,7 +788,7 @@ function user_home(user: string): string | undefined { // getent is the portable lookup on Linux; on macOS getent is absent but the // /root/.pkgx scenario this guards against doesn't arise there in practice. try { - const out = new Deno.Command("/usr/bin/getent", { + const out = new Deno.Command("getent", { args: ["passwd", user], }).outputSync(); if (!out.success) return undefined; From 3c85e6d79613120002b85bafb5ac90a401e06897 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 11 May 2026 19:39:39 +0200 Subject: [PATCH 03/22] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- pkgm.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkgm.ts b/pkgm.ts index 28509c3..aec2107 100755 --- a/pkgm.ts +++ b/pkgm.ts @@ -313,7 +313,7 @@ async function query_pkgx( const reachable = pkgx_reachable_as(pkgx, sudoUser); if (reachable) { cmd = "/usr/bin/sudo"; - cmd_args = ["-u", sudoUser, reachable, ...args]; + cmd_args = ["-u", sudoUser, "--", reachable, ...args]; // Override HOME, or pkgx will cache back under /root/ where sudoUser // can't reach it on the next invocation. const home = user_home(sudoUser); From 373b10094f4fdc313118b0a9030acbbf44f989a8 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 11 May 2026 20:46:35 +0200 Subject: [PATCH 04/22] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- pkgm.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pkgm.ts b/pkgm.ts index aec2107..0db4627 100755 --- a/pkgm.ts +++ b/pkgm.ts @@ -787,8 +787,12 @@ function install_prefix() { function user_home(user: string): string | undefined { // getent is the portable lookup on Linux; on macOS getent is absent but the // /root/.pkgx scenario this guards against doesn't arise there in practice. + const getent = existsSync("/usr/bin/getent") + ? "/usr/bin/getent" + : "/bin/getent"; + try { - const out = new Deno.Command("getent", { + const out = new Deno.Command(getent, { args: ["passwd", user], }).outputSync(); if (!out.success) return undefined; From bfcf4a72ee561ea2dd6702a3bbba6c2ef952ebcf Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 11 May 2026 21:30:53 +0200 Subject: [PATCH 05/22] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- pkgm.ts | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/pkgm.ts b/pkgm.ts index 0db4627..a6fe038 100755 --- a/pkgm.ts +++ b/pkgm.ts @@ -812,14 +812,20 @@ function pkgx_reachable_as(current: string, user: string): string | undefined { const root = join(home, ".pkgx/pkgx.sh"); if (existsSync(root)) { let best: { v: SemVer; path: string } | undefined; - for (const entry of Deno.readDirSync(root)) { - if (!entry.isDirectory || !entry.name.startsWith("v")) continue; - try { - const v = new SemVer(entry.name.slice(1)); - const path = join(root, entry.name, "bin/pkgx"); - if (!existsSync(path)) continue; - if (!best || v.gt(best.v)) best = { v, path }; - } catch { /* skip malformed version dir */ } + try { + if (Deno.statSync(root).isDirectory) { + for (const entry of Deno.readDirSync(root)) { + if (!entry.isDirectory || !entry.name.startsWith("v")) continue; + try { + const v = new SemVer(entry.name.slice(1)); + const path = join(root, entry.name, "bin/pkgx"); + if (!existsSync(path)) continue; + if (!best || v.gt(best.v)) best = { v, path }; + } catch { /* skip malformed version dir */ } + } + } + } catch { + // Ignore unreadable/non-directory pkgx.sh roots and fall back to other locations. } if (best) return best.path; } From 3470f5299d8fee946db66c0a120dadb935e1447a Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 11 May 2026 22:42:10 +0200 Subject: [PATCH 06/22] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- pkgm.ts | 63 ++++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 53 insertions(+), 10 deletions(-) diff --git a/pkgm.ts b/pkgm.ts index a6fe038..c8dae68 100755 --- a/pkgm.ts +++ b/pkgm.ts @@ -784,25 +784,68 @@ function install_prefix() { } } -function user_home(user: string): string | undefined { - // getent is the portable lookup on Linux; on macOS getent is absent but the - // /root/.pkgx scenario this guards against doesn't arise there in practice. - const getent = existsSync("/usr/bin/getent") - ? "/usr/bin/getent" - : "/bin/getent"; +function user_home_from_passwd(user: string): string | undefined { + try { + const passwd = Deno.readTextFileSync("/etc/passwd"); + for (const line of passwd.split("\n")) { + if (!line || line.startsWith("#")) continue; + const fields = line.split(":"); + if (fields[0] === user) return fields[5] || undefined; + } + } catch { + // Ignore unreadable or absent passwd database and fall back to other lookups. + } + + return undefined; +} + +function user_home_from_dscl(user: string): string | undefined { + if (!existsSync("/usr/bin/dscl")) return undefined; try { - const out = new Deno.Command(getent, { - args: ["passwd", user], + const out = new Deno.Command("/usr/bin/dscl", { + args: [".", "-read", `/Users/${user}`, "NFSHomeDirectory"], }).outputSync(); if (!out.success) return undefined; - const fields = new TextDecoder().decode(out.stdout).trim().split(":"); - return fields[5] || undefined; + + const line = new TextDecoder().decode(out.stdout).trim(); + const prefix = "NFSHomeDirectory:"; + if (!line.startsWith(prefix)) return undefined; + + const home = line.slice(prefix.length).trim(); + return home || undefined; } catch { return undefined; } } +function user_home(user: string): string | undefined { + // Prefer getent where available, but fall back to passwd parsing and macOS + // dscl so HOME can still be resolved when dropping privileges on systems + // without getent. + const getent = existsSync("/usr/bin/getent") + ? "/usr/bin/getent" + : existsSync("/bin/getent") + ? "/bin/getent" + : undefined; + + if (getent) { + try { + const out = new Deno.Command(getent, { + args: ["passwd", user], + }).outputSync(); + if (out.success) { + const fields = new TextDecoder().decode(out.stdout).trim().split(":"); + if (fields[5]) return fields[5]; + } + } catch { + // Ignore getent lookup failures and try portable fallbacks below. + } + } + + return user_home_from_passwd(user) ?? user_home_from_dscl(user); +} + function pkgx_reachable_as(current: string, user: string): string | undefined { if (reachable_as(current, user)) return current; From 069770d8db8bed021fa079af65558a065ca08aca Mon Sep 17 00:00:00 2001 From: tannevaled Date: Tue, 12 May 2026 08:07:08 +0200 Subject: [PATCH 07/22] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- pkgm.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/pkgm.ts b/pkgm.ts index c8dae68..41ee018 100755 --- a/pkgm.ts +++ b/pkgm.ts @@ -883,9 +883,15 @@ function reachable_as(p: string, user: string): boolean { // Conservative heuristic: private home dirs are typically mode 700, so a // path under another user's home is unreachable. System paths and the // user's own home are assumed reachable. - if (p.startsWith("/root/")) return user === "root"; - const m = p.match(/^\/home\/([^/]+)\//); - if (m) return m[1] === user; + const home = user_home(user); + if (home && (p === home || p.startsWith(`${home}/`))) return true; + + if (p === "/root" || p.startsWith("/root/")) return false; + if (p === "/var/root" || p.startsWith("/var/root/")) return false; + + const m = p.match(/^\/(home|Users)\/([^/]+)(?:\/|$)/); + if (m) return false; + return true; } From e3530f1c10d4424c1af1076fa08ed7ab30435b29 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Tue, 12 May 2026 08:15:59 +0200 Subject: [PATCH 08/22] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- pkgm.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pkgm.ts b/pkgm.ts index 41ee018..d5d7669 100755 --- a/pkgm.ts +++ b/pkgm.ts @@ -889,8 +889,7 @@ function reachable_as(p: string, user: string): boolean { if (p === "/root" || p.startsWith("/root/")) return false; if (p === "/var/root" || p.startsWith("/var/root/")) return false; - const m = p.match(/^\/(home|Users)\/([^/]+)(?:\/|$)/); - if (m) return false; + if (p.match(/^\/(home|Users)\/([^/]+)(?:\/|$)/)) return false; return true; } From e1104ead445365dc721a2a375b35952c8baa073d Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 18 May 2026 13:25:51 +0200 Subject: [PATCH 09/22] address Copilot review comments on #86 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - expand single-line `} catch { /* … */ }` to the multi-line form `deno fmt` expects, so the existing `deno fmt --check .` CI job stops failing on it. - extract the `pkgx >= 2.4.0` floor from `get_pkgx()` into a shared `pkgx_meets_minimum()` helper and apply it to every fallback path in `pkgx_reachable_as()` (versioned pkgx.sh dir, ~/.local/bin/pkgx, /usr/local/bin/pkgx). Previously a stale older pkgx could be returned as the fallback when the original-root pkgx was new enough, breaking the JSON query. - exempt the shared Linuxbrew prefix (default /home/linuxbrew/.linuxbrew, or \$HOMEBREW_PREFIX when set) from `reachable_as()`'s /home// rejection. standardPath() already treats it as a system pkgx location; without this exemption a Linuxbrew-installed pkgx would force the root-execution fallback, recreating the root-owned cache problem this whole change exists to prevent. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkgm.ts | 57 ++++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 44 insertions(+), 13 deletions(-) diff --git a/pkgm.ts b/pkgm.ts index d5d7669..e42900e 100755 --- a/pkgm.ts +++ b/pkgm.ts @@ -535,18 +535,28 @@ function symlink_with_overwrite(src: string, dst: string) { Deno.symlinkSync(src, dst); } +const PKGX_MIN_VERSION = new SemVer("2.4.0"); + +function pkgx_meets_minimum(path: string): boolean { + try { + const out = new Deno.Command(path, { args: ["--version"] }).outputSync(); + if (!out.success) return false; + const match = new TextDecoder().decode(out.stdout).match( + /^pkgx (\d+\.\d+\.\d+)/, + ); + if (!match) return false; + return new SemVer(match[1]).gte(PKGX_MIN_VERSION); + } catch { + return false; + } +} + function get_pkgx() { for (const path of Deno.env.get("PATH")!.split(":")) { const pkgx = join(path, "pkgx"); - if (existsSync(pkgx)) { - const out = new Deno.Command(pkgx, { args: ["--version"] }).outputSync(); - const stdout = new TextDecoder().decode(out.stdout); - const match = stdout.match(/^pkgx (\d+\.\d+\.\d+)/); - if (!match || new SemVer(match[1]).lt(new SemVer("2.4.0"))) { - Deno.exit(1); - } - return pkgx; - } + if (!existsSync(pkgx)) continue; + if (!pkgx_meets_minimum(pkgx)) Deno.exit(1); + return pkgx; } throw new Error("no `pkgx` found in `$PATH`"); } @@ -847,11 +857,16 @@ function user_home(user: string): string | undefined { } function pkgx_reachable_as(current: string, user: string): string | undefined { + // The caller has already enforced PKGX_MIN_VERSION for `current` via + // get_pkgx(); fallback candidates have not, so each return path below + // re-checks with pkgx_meets_minimum() to avoid handing back an + // unsupported binary (per #86 review). if (reachable_as(current, user)) return current; const home = user_home(user); if (home) { - // Versioned pkgx.sh layout: ~/.pkgx/pkgx.sh/v/bin/pkgx — pick the highest. + // Versioned pkgx.sh layout: ~/.pkgx/pkgx.sh/v/bin/pkgx — pick the + // highest version that meets the minimum. const root = join(home, ".pkgx/pkgx.sh"); if (existsSync(root)) { let best: { v: SemVer; path: string } | undefined; @@ -861,10 +876,13 @@ function pkgx_reachable_as(current: string, user: string): string | undefined { if (!entry.isDirectory || !entry.name.startsWith("v")) continue; try { const v = new SemVer(entry.name.slice(1)); + if (v.lt(PKGX_MIN_VERSION)) continue; const path = join(root, entry.name, "bin/pkgx"); if (!existsSync(path)) continue; if (!best || v.gt(best.v)) best = { v, path }; - } catch { /* skip malformed version dir */ } + } catch { + // skip malformed version dir + } } } } catch { @@ -873,9 +891,14 @@ function pkgx_reachable_as(current: string, user: string): string | undefined { if (best) return best.path; } const local = join(home, ".local/bin/pkgx"); - if (existsSync(local)) return local; + if (existsSync(local) && pkgx_meets_minimum(local)) return local; + } + if ( + existsSync("/usr/local/bin/pkgx") && + pkgx_meets_minimum("/usr/local/bin/pkgx") + ) { + return "/usr/local/bin/pkgx"; } - if (existsSync("/usr/local/bin/pkgx")) return "/usr/local/bin/pkgx"; return undefined; } @@ -886,6 +909,14 @@ function reachable_as(p: string, user: string): boolean { const home = user_home(user); if (home && (p === home || p.startsWith(`${home}/`))) return true; + // Shared Linuxbrew prefix lives under /home but is world-traversable and + // is treated as a system pkgx location by standardPath(). Without this + // exemption a pkgx installed via Linuxbrew would force the root-execution + // fallback, recreating the root-owned cache problem this code avoids + // (per #86 review). Honour $HOMEBREW_PREFIX in case it's elsewhere. + const brew = Deno.env.get("HOMEBREW_PREFIX") ?? "/home/linuxbrew/.linuxbrew"; + if (p === brew || p.startsWith(`${brew}/`)) return true; + if (p === "/root" || p.startsWith("/root/")) return false; if (p === "/var/root" || p.startsWith("/var/root/")) return false; From 4264c8bec7e71a64118abfe75ee9086e850b64d6 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 18 May 2026 13:38:49 +0200 Subject: [PATCH 10/22] fix temporal-dead-zone on PKGX_MIN_VERSION MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit placed `const PKGX_MIN_VERSION = new SemVer("2.4.0")` above pkgx_meets_minimum() near line ~540, but the top-level dispatch block (parsedArgs switch at lines 60–126) calls install() → get_pkgx() → pkgx_meets_minimum() at module-init time, which is BEFORE execution reaches line 540. `const` declarations are hoisted in name only (TDZ), unlike `function` declarations whose bodies are hoisted — so the reference threw `Cannot access 'PKGX_MIN_VERSION' before initialization` at runtime, causing every `pkgm install` to fail silently with exit 1. Neither `deno fmt`, `deno lint`, nor `deno check` detect this — it's a module-init ordering issue invisible to static analysis. Pushed CI on #86 caught it via the existing `./pkgm.ts i git` test. Move the constant up to module-scope right after the `hydrate` import, above any function that's reachable from top-level code. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkgm.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/pkgm.ts b/pkgm.ts index e42900e..62cae15 100755 --- a/pkgm.ts +++ b/pkgm.ts @@ -13,6 +13,13 @@ import { ensureDir, existsSync, walk } from "jsr:@std/fs@^1"; import { parseArgs } from "jsr:@std/cli@^1"; const { hydrate } = plumbing; +// Module-scope SemVer literal: must be defined before any function that +// reads it can be called from top-level code below. `const` declarations +// are hoisted in name only (TDZ), so placing this further down the file +// triggered "Cannot access 'PKGX_MIN_VERSION' before initialization" once +// install()/get_pkgx() ran at module-init time. +const PKGX_MIN_VERSION = new SemVer("2.4.0"); + function standardPath() { let path = "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"; @@ -535,8 +542,6 @@ function symlink_with_overwrite(src: string, dst: string) { Deno.symlinkSync(src, dst); } -const PKGX_MIN_VERSION = new SemVer("2.4.0"); - function pkgx_meets_minimum(path: string): boolean { try { const out = new Deno.Command(path, { args: ["--version"] }).outputSync(); From 1c8cda332792ab609dd221c98512e14dc13d5c20 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 18 May 2026 13:54:19 +0200 Subject: [PATCH 11/22] ci: fix sudo-install test 1 to not assert on /root/.pkgx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous assertion `find /root/.pkgx -newer marker` is fundamentally incompatible with the shebang resolution: when `sudo ./pkgm.ts …` runs, the kernel reads `#!/usr/bin/env -S pkgx --quiet deno^2.1 run …` and execs `pkgx` as root *before any pkgm.ts code runs*. That outer pkgx caches deno under $HOME/.pkgx, which under sudo as root resolves to /root/.pkgx. Those cache files are unavoidable and unrelated to whether pkgm's *inner* pkgx invocation drops privileges and overrides HOME. As a consequence the sudo-install job has been failing on main ever since #85 merged (run 26006991059) — the test caught the shebang's deno cache and reported it as a HOME-override failure regardless of which pkgm.ts version it ran against. The Copilot autofix on #85 had moved the assertion from `test -e` to `find -newer marker` but didn't remove it, so the false positive persisted. Replace the negative check with a positive one against $HOME/.pkgx: - assert new files appeared under $HOME/.pkgx after the install (proves inner pkgx pointed HOME at the invoking user) - assert none of those files are root-owned (proves the privilege drop in query_pkgx took effect) If either property fails, one of HOME override or privilege drop is broken in pkgm.ts — which is what the job is meant to guard. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1bbb50b..4df07b5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -124,26 +124,33 @@ jobs: - name: sudo install drops privileges and overrides HOME run: | set -eux - # marker to scope ownership checks to files created by this install + # marker to scope checks to files created by this install touch /tmp/pkgm-sudo-marker sudo ./pkgm.ts i hyperfine test -x /usr/local/bin/hyperfine - # HOME override: pkgx must not have created anything under /root/.pkgx - # during this install. We scope the check to paths newer than the - # marker so a pre-existing /root/.pkgx from the runner image or - # setup action does not cause a false failure. - created_under_root=$(sudo find /root/.pkgx -newer /tmp/pkgm-sudo-marker -print 2>/dev/null || true) - if [ -n "$created_under_root" ]; then - echo "::error::pkgx cached under /root/.pkgx — HOME override failed" - echo "$created_under_root" + + # HOME override + privilege drop are both validated via the pkg + # cache under $HOME/.pkgx. We deliberately do NOT assert that + # /root/.pkgx is empty: the shebang's `pkgx --quiet deno^2.1 run …` + # runs as root before any pkgm.ts code executes, and that outer + # pkgx caches under $HOME/.pkgx which resolves to /root/.pkgx + # under sudo. That cache is unavoidable and unrelated to whether + # pkgm's *inner* pkgx call dropped privileges. + # + # If pkgm's inner pkgx call dropped privileges and pointed HOME at + # the invoking user, the pkg cache lands under $HOME/.pkgx and is + # owned by the invoking user. If either step failed, the cache + # would be missing here or contain root-owned files. + + new_files=$(sudo find "$HOME/.pkgx" -newer /tmp/pkgm-sudo-marker -type f -print 2>/dev/null | head -1 || true) + if [ -z "$new_files" ]; then + echo "::error::no new files under \$HOME/.pkgx — inner pkgx did not cache to invoking user's tree" exit 1 fi - # Privilege drop: nothing newly created under ~/.pkgx should be - # owned by root. Any root-owned file here means pkgx ran as root - # despite SUDO_USER being set. + owned_by_root=$(sudo find "$HOME/.pkgx" -newer /tmp/pkgm-sudo-marker -user root -print 2>/dev/null || true) if [ -n "$owned_by_root" ]; then - echo "::error::pkgx cache files created as root under \$HOME/.pkgx:" + echo "::error::pkgx cache files created as root under \$HOME/.pkgx — privilege drop failed:" echo "$owned_by_root" exit 1 fi From ea4dab6bc6835297f9c06d8ea1d9a2025067498d Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 18 May 2026 14:09:24 +0200 Subject: [PATCH 12/22] ci: emit diagnostic ::warning:: annotations from sudo-install The positive cache check landed but is still failing on the runner: "no new files under \$HOME/.pkgx". To diagnose without log access I need to surface the relevant state via the annotations API. Add four warnings that capture, on every run: - which pkgx the sudo session resolves (the path get_pkgx() returns) - what new files appeared under /root/.pkgx (shebang's deno cache plus any cache the inner pkgx made there) - what new files appeared under \$HOME/.pkgx (the privilege-drop target) - the first 500 bytes of pkgm.ts's stderr, captured with PKGM_DEBUG=1 so the "is not reachable as " fallback diagnostic surfaces To be reverted once the failure mode is identified. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4df07b5..079f761 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,12 +123,31 @@ jobs: - name: sudo install drops privileges and overrides HOME run: | - set -eux + set -ux # marker to scope checks to files created by this install touch /tmp/pkgm-sudo-marker - sudo ./pkgm.ts i hyperfine + # Run pkgm with PKGM_DEBUG so the "is not reachable as $USER" + # diagnostic surfaces when pkgx_reachable_as falls through; we + # tee stderr so the diagnostic is visible in the job log as well + # as captured for the warning emission below. + sudo PKGM_DEBUG=1 ./pkgm.ts i hyperfine 2> >(tee /tmp/pkgm-sudo.stderr >&2) + rc=$? + set -e + test $rc -eq 0 test -x /usr/local/bin/hyperfine + # Surface enough state as ::warning:: annotations to diagnose + # which branch query_pkgx() took. Failure cases: + # - "pkgx_reachable_as returned undefined" → privilege drop + # skipped → pkgx runs as root with HOME=/root → cache lands + # under /root/.pkgx, none under $HOME/.pkgx. + # - "user_home() failed" → reachable_as falls through to its + # /home// rejection regex. + echo "::warning::diag pkgx-via-sudo: $(sudo command -v pkgx 2>&1 || echo none)" + echo "::warning::diag root-new-files: $(sudo find /root/.pkgx -newer /tmp/pkgm-sudo-marker -print 2>/dev/null | head -8 | tr '\n' '|' || echo none)" + echo "::warning::diag user-new-files: $(sudo find $HOME/.pkgx -newer /tmp/pkgm-sudo-marker -print 2>/dev/null | head -8 | tr '\n' '|' || echo none)" + echo "::warning::diag pkgm-stderr: $(tr '\n' '|' < /tmp/pkgm-sudo.stderr | head -c 500 || echo none)" + # HOME override + privilege drop are both validated via the pkg # cache under $HOME/.pkgx. We deliberately do NOT assert that # /root/.pkgx is empty: the shebang's `pkgx --quiet deno^2.1 run …` @@ -136,11 +155,6 @@ jobs: # pkgx caches under $HOME/.pkgx which resolves to /root/.pkgx # under sudo. That cache is unavoidable and unrelated to whether # pkgm's *inner* pkgx call dropped privileges. - # - # If pkgm's inner pkgx call dropped privileges and pointed HOME at - # the invoking user, the pkg cache lands under $HOME/.pkgx and is - # owned by the invoking user. If either step failed, the cache - # would be missing here or contain root-owned files. new_files=$(sudo find "$HOME/.pkgx" -newer /tmp/pkgm-sudo-marker -type f -print 2>/dev/null | head -1 || true) if [ -z "$new_files" ]; then From 18e0471e34d3e897f74906b2fcd230feeb15faa5 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 18 May 2026 14:11:32 +0200 Subject: [PATCH 13/22] ci: check directories, not files, under \$HOME/.pkgx The diagnostic ::warning:: annotations from ea4dab6 confirmed the install actually works: hyperfine landed under /home/runner/.pkgx/crates.io/hyperfine/v1.20.0/ with the privilege drop and HOME override both firing as intended (no "is not reachable as " diagnostic on pkgm's stderr, only deno download lines). The remaining failure was the assertion itself: `find -type f -newer marker` returns empty because tar -x preserves the archive's original mtimes on extracted files, so the binary's mtime predates the marker. Only the directories pkgx mkdirs during extraction have a current mtime. Switch the positive check to `-type d` and drop the diagnostic warnings now that the failure mode is identified. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 45 ++++++++++++++-------------------------- 1 file changed, 16 insertions(+), 29 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 079f761..7eb92b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,48 +123,35 @@ jobs: - name: sudo install drops privileges and overrides HOME run: | - set -ux - # marker to scope checks to files created by this install + set -eux + # marker to scope checks to entries created by this install touch /tmp/pkgm-sudo-marker - # Run pkgm with PKGM_DEBUG so the "is not reachable as $USER" - # diagnostic surfaces when pkgx_reachable_as falls through; we - # tee stderr so the diagnostic is visible in the job log as well - # as captured for the warning emission below. - sudo PKGM_DEBUG=1 ./pkgm.ts i hyperfine 2> >(tee /tmp/pkgm-sudo.stderr >&2) - rc=$? - set -e - test $rc -eq 0 + sudo ./pkgm.ts i hyperfine test -x /usr/local/bin/hyperfine - # Surface enough state as ::warning:: annotations to diagnose - # which branch query_pkgx() took. Failure cases: - # - "pkgx_reachable_as returned undefined" → privilege drop - # skipped → pkgx runs as root with HOME=/root → cache lands - # under /root/.pkgx, none under $HOME/.pkgx. - # - "user_home() failed" → reachable_as falls through to its - # /home// rejection regex. - echo "::warning::diag pkgx-via-sudo: $(sudo command -v pkgx 2>&1 || echo none)" - echo "::warning::diag root-new-files: $(sudo find /root/.pkgx -newer /tmp/pkgm-sudo-marker -print 2>/dev/null | head -8 | tr '\n' '|' || echo none)" - echo "::warning::diag user-new-files: $(sudo find $HOME/.pkgx -newer /tmp/pkgm-sudo-marker -print 2>/dev/null | head -8 | tr '\n' '|' || echo none)" - echo "::warning::diag pkgm-stderr: $(tr '\n' '|' < /tmp/pkgm-sudo.stderr | head -c 500 || echo none)" - - # HOME override + privilege drop are both validated via the pkg - # cache under $HOME/.pkgx. We deliberately do NOT assert that + # HOME override + privilege drop are validated via the pkg cache + # under $HOME/.pkgx. We deliberately do NOT assert that # /root/.pkgx is empty: the shebang's `pkgx --quiet deno^2.1 run …` # runs as root before any pkgm.ts code executes, and that outer # pkgx caches under $HOME/.pkgx which resolves to /root/.pkgx # under sudo. That cache is unavoidable and unrelated to whether # pkgm's *inner* pkgx call dropped privileges. - - new_files=$(sudo find "$HOME/.pkgx" -newer /tmp/pkgm-sudo-marker -type f -print 2>/dev/null | head -1 || true) - if [ -z "$new_files" ]; then - echo "::error::no new files under \$HOME/.pkgx — inner pkgx did not cache to invoking user's tree" + # + # We check for newly created entries (directories specifically), + # not files: tar -x preserves the archive's original mtimes on + # extracted files, so file mtimes are typically *older* than the + # marker. Directories are created fresh by `mkdir` during + # extraction and reliably have a current mtime. + + new_dirs=$(sudo find "$HOME/.pkgx" -newer /tmp/pkgm-sudo-marker -type d -print 2>/dev/null | head -1 || true) + if [ -z "$new_dirs" ]; then + echo "::error::no new directories under \$HOME/.pkgx — inner pkgx did not cache to invoking user's tree" exit 1 fi owned_by_root=$(sudo find "$HOME/.pkgx" -newer /tmp/pkgm-sudo-marker -user root -print 2>/dev/null || true) if [ -n "$owned_by_root" ]; then - echo "::error::pkgx cache files created as root under \$HOME/.pkgx — privilege drop failed:" + echo "::error::pkgx cache entries created as root under \$HOME/.pkgx — privilege drop failed:" echo "$owned_by_root" exit 1 fi From 5b8fa2a2b0913ab30bff768912f0842880158f88 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 18 May 2026 15:26:40 +0200 Subject: [PATCH 14/22] address remaining Copilot remark: verify versioned pkgx candidate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot's review on 18e0471 flagged that the versioned-layout fallback (`~/.pkgx/pkgx.sh/v/bin/pkgx`) picked candidates based only on the directory-name semver and existence of the binary, while the other fallback paths (~/.local/bin/pkgx, /usr/local/bin/pkgx) verify candidates with pkgx_meets_minimum(). A stale, non-executable, or version-mismatched v*/bin/pkgx could therefore be returned and break the inner sudo invocation. Keep the directory-name semver as a cheap pre-filter — it lets us skip old-version dirs without spawning --version per entry — but verify the actual binary with pkgx_meets_minimum() before accepting it as `best`. The other two Copilot remarks on this commit either don't apply (Linuxbrew exemption already landed in e1104ea, sits before the /home|Users/ rejection) or are stale (the ci.yml comment refers to `set -ux` / `rc=$?` from ea4dab6 which I already removed in 18e0471; the current step uses `set -eux`). Co-Authored-By: Claude Opus 4.7 (1M context) --- pkgm.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkgm.ts b/pkgm.ts index 62cae15..081702a 100755 --- a/pkgm.ts +++ b/pkgm.ts @@ -884,6 +884,11 @@ function pkgx_reachable_as(current: string, user: string): string | undefined { if (v.lt(PKGX_MIN_VERSION)) continue; const path = join(root, entry.name, "bin/pkgx"); if (!existsSync(path)) continue; + // Directory-name version is a cheap pre-filter; verify the + // actual binary too, matching the other fallback paths so a + // stale or non-executable `v*/bin/pkgx` can't be returned + // (per #86 review). + if (!pkgx_meets_minimum(path)) continue; if (!best || v.gt(best.v)) best = { v, path }; } catch { // skip malformed version dir From 5b518671024792797ecd38f37fbd0bc003054793 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 18 May 2026 15:34:17 +0200 Subject: [PATCH 15/22] ci: run sudo-install on macOS too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The job was pinned to ubuntu-latest because the previous /root/.pkgx assertion was Linux-specific. Now that the assertion targets \$HOME/.pkgx instead and pkgm.ts's user_home() has a dscl fallback for macOS, both tests are portable — with one platform difference: root's home is /root on Linux but /var/root on macOS. `sudo mkdir /root` on modern macOS fails because the system volume is read-only and new top-level directories can't be created without /etc/synthetic.conf. - Add a {ubuntu-latest, macos-latest} matrix. - Resolve root's home via `eval echo ~root` in the fallback step so the same script stages pkgx under /root on Linux and /var/root on macOS. Test 1 (privilege drop + HOME override) already used only \$HOME/.pkgx and POSIX find flags, so it ports without changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7eb92b6..901923e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,11 +112,17 @@ jobs: # Validates `sudo pkgm install` behaviour fixed in 2b33f20: # - privilege drop so pkgx cache stays owned by $SUDO_USER, not root # - HOME override so the cache lands under the invoking user's tree - # - fallback to running pkgx as root when it lives under /root/.pkgx + # - fallback to running pkgx as root when it lives under root's home # and is therefore unreachable to $SUDO_USER (pkgxdev/pkgm#68) - # Linux-only: the /root/.pkgx scenario doesn't arise on macOS in practice. + # The root-home path differs by OS (/root on Linux, /var/root on macOS); + # we resolve it dynamically via `eval echo ~root` rather than hard-coding. sudo-install: - runs-on: ubuntu-latest + strategy: + matrix: + os: + - ubuntu-latest + - macos-latest + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - uses: pkgxdev/setup@v4 @@ -158,24 +164,30 @@ jobs: - name: sudo install falls back when pkgx is unreachable as $SUDO_USER # Must be last — this step strips pkgx from every location the - # runner user can reach, leaving only /root/.pkgx, which the - # subsequent shebang resolution still needs to walk through sudo. + # runner user can reach, leaving only root's private pkgx, which + # the subsequent shebang resolution still needs to walk through sudo. run: | set -eux - # Stage pkgx exclusively under /root so that reachable_as() returns - # false for the runner user and no alternative is found. + # Resolve root's home portably: /root on Linux, /var/root on macOS. + # Hard-coding /root would fail on macOS because the system volume + # is read-only and `sudo mkdir /root` can't create a new top-level + # dir without /etc/synthetic.conf. + root_home=$(eval echo ~root) + + # Stage pkgx exclusively under root's home so that reachable_as() + # returns false for the runner user and no alternative is found. pkgx_src=$(command -v pkgx) - sudo mkdir -p /root/.pkgx/bin - sudo cp "$pkgx_src" /root/.pkgx/bin/pkgx + sudo mkdir -p "$root_home/.pkgx/bin" + sudo cp "$pkgx_src" "$root_home/.pkgx/bin/pkgx" # Wipe every alternative the resolver looks for: # ~/.pkgx/pkgx.sh/v*/bin/pkgx, ~/.local/bin/pkgx, /usr/local/bin/pkgx rm -rf "$HOME/.pkgx" sudo rm -f /usr/local/bin/pkgx "$HOME/.local/bin/pkgx" - # Invoke pkgm.ts with the /root pkgx on PATH. `sudo env PATH=...` + # Invoke pkgm.ts with the staged pkgx on PATH. `sudo env PATH=...` # is the canonical way around the default secure_path policy in - # Ubuntu's sudoers. + # Ubuntu's sudoers; macOS sudo respects the explicit env too. set +e - out=$(sudo env PATH="/root/.pkgx/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" ./pkgm.ts i gum 2>&1) + out=$(sudo env PATH="$root_home/.pkgx/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" ./pkgm.ts i gum 2>&1) rc=$? set -e echo "$out" From c700b498f54edbeac49d62e31713399e8efaf09e Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 18 May 2026 15:37:11 +0200 Subject: [PATCH 16/22] ci: add diagnostic warnings to sudo-install (debugging macOS) --- .github/workflows/ci.yml | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 901923e..3d8902a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -129,10 +129,27 @@ jobs: - name: sudo install drops privileges and overrides HOME run: | + # Diagnostic prelude (always-on while debugging macOS) — emits + # ::warning:: annotations visible via the check-runs/annotations + # API even when raw logs require auth. + echo "::warning::env uname=$(uname) user=$USER home=$HOME" + echo "::warning::env pkgx-path: $(command -v pkgx 2>&1 || echo none)" + echo "::warning::env pkgx-version: $(pkgx --version 2>&1 || echo none)" + echo "::warning::env sudo-pkgx-path: $(sudo bash -c 'command -v pkgx' 2>&1 || echo none)" + set -eux # marker to scope checks to entries created by this install touch /tmp/pkgm-sudo-marker - sudo ./pkgm.ts i hyperfine + set +e + sudo ./pkgm.ts i hyperfine 2> >(tee /tmp/pkgm-sudo.stderr >&2) + rc=$? + set -e + echo "::warning::diag install-exit: $rc" + echo "::warning::diag pkgm-stderr-head: $(head -c 600 /tmp/pkgm-sudo.stderr 2>/dev/null | tr '\n' '|' || echo none)" + echo "::warning::diag user-new: $(sudo find $HOME/.pkgx -newer /tmp/pkgm-sudo-marker -print 2>/dev/null | head -6 | tr '\n' '|' || echo none)" + echo "::warning::diag roothome: $(eval echo ~root)" + echo "::warning::diag roothome-new: $(sudo find $(eval echo ~root)/.pkgx -newer /tmp/pkgm-sudo-marker -print 2>/dev/null | head -6 | tr '\n' '|' || echo none)" + test $rc -eq 0 test -x /usr/local/bin/hyperfine # HOME override + privilege drop are validated via the pkg cache From cd3133bdc4c5aff430f21439a55070701616243a Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 18 May 2026 15:39:13 +0200 Subject: [PATCH 17/22] ci: expand sudo-install stderr diagnostic (head+tail, strip ANSI) --- .github/workflows/ci.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d8902a..9f2e1ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -145,7 +145,11 @@ jobs: rc=$? set -e echo "::warning::diag install-exit: $rc" - echo "::warning::diag pkgm-stderr-head: $(head -c 600 /tmp/pkgm-sudo.stderr 2>/dev/null | tr '\n' '|' || echo none)" + # Dump stderr in chunks (annotations are limited to ~4KB each). + stripped=$(sed -E 's/\x1b\[[0-9;]*[A-Za-z]//g; s/\r//g' /tmp/pkgm-sudo.stderr 2>/dev/null || true) + echo "::warning::diag stderr-len: ${#stripped}" + echo "::warning::diag stderr-tail: $(echo "$stripped" | tail -c 1500 | tr '\n' '|' || echo none)" + echo "::warning::diag stderr-head: $(echo "$stripped" | head -c 1500 | tr '\n' '|' || echo none)" echo "::warning::diag user-new: $(sudo find $HOME/.pkgx -newer /tmp/pkgm-sudo-marker -print 2>/dev/null | head -6 | tr '\n' '|' || echo none)" echo "::warning::diag roothome: $(eval echo ~root)" echo "::warning::diag roothome-new: $(sudo find $(eval echo ~root)/.pkgx -newer /tmp/pkgm-sudo-marker -print 2>/dev/null | head -6 | tr '\n' '|' || echo none)" From b94c2ce440c59c6d60f3dfd51c3b1669e38bddc2 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 18 May 2026 15:42:16 +0200 Subject: [PATCH 18/22] ci: invoke sudo with -H so HOME is reset on macOS macOS sudoers keeps HOME in env_keep by default; plain `sudo ./pkgm.ts` on macOS preserves HOME=\$HOME for the shebang's outer pkgx invocation, which runs as ROOT and writes root-owned dirs into \$HOME/.pkgx for the deno self-cache. The inner pkgx (dropped back to \$SUDO_USER by pkgm's fix) then EACCES on those dirs, killing the install with Error: Os { code: 13, kind: PermissionDenied, message: "Permission denied" } (captured live via the diagnostic ::warning:: annotations). `sudo -H` forces HOME to root's home, matching Linux sudoers' env_reset behaviour. Harmless on Linux (HOME wasn't preserved there to begin with), required for hermetic test runs on macOS. Also drop the temporary diagnostic warnings; they served their purpose. --- .github/workflows/ci.yml | 35 ++++++++++++----------------------- 1 file changed, 12 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f2e1ff..9f05645 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -129,31 +129,20 @@ jobs: - name: sudo install drops privileges and overrides HOME run: | - # Diagnostic prelude (always-on while debugging macOS) — emits - # ::warning:: annotations visible via the check-runs/annotations - # API even when raw logs require auth. - echo "::warning::env uname=$(uname) user=$USER home=$HOME" - echo "::warning::env pkgx-path: $(command -v pkgx 2>&1 || echo none)" - echo "::warning::env pkgx-version: $(pkgx --version 2>&1 || echo none)" - echo "::warning::env sudo-pkgx-path: $(sudo bash -c 'command -v pkgx' 2>&1 || echo none)" - set -eux # marker to scope checks to entries created by this install touch /tmp/pkgm-sudo-marker - set +e - sudo ./pkgm.ts i hyperfine 2> >(tee /tmp/pkgm-sudo.stderr >&2) - rc=$? - set -e - echo "::warning::diag install-exit: $rc" - # Dump stderr in chunks (annotations are limited to ~4KB each). - stripped=$(sed -E 's/\x1b\[[0-9;]*[A-Za-z]//g; s/\r//g' /tmp/pkgm-sudo.stderr 2>/dev/null || true) - echo "::warning::diag stderr-len: ${#stripped}" - echo "::warning::diag stderr-tail: $(echo "$stripped" | tail -c 1500 | tr '\n' '|' || echo none)" - echo "::warning::diag stderr-head: $(echo "$stripped" | head -c 1500 | tr '\n' '|' || echo none)" - echo "::warning::diag user-new: $(sudo find $HOME/.pkgx -newer /tmp/pkgm-sudo-marker -print 2>/dev/null | head -6 | tr '\n' '|' || echo none)" - echo "::warning::diag roothome: $(eval echo ~root)" - echo "::warning::diag roothome-new: $(sudo find $(eval echo ~root)/.pkgx -newer /tmp/pkgm-sudo-marker -print 2>/dev/null | head -6 | tr '\n' '|' || echo none)" - test $rc -eq 0 + + # Why `sudo -H`: macOS sudoers keeps HOME in env_keep by default, + # so plain `sudo ./pkgm.ts` would preserve HOME=$HOME for the + # shebang's outer `pkgx --quiet deno^2.1 run …` invocation — + # which runs as ROOT and would then write root-owned dirs into + # $HOME/.pkgx for the deno self-cache. The inner pkgx (privilege- + # dropped back to $SUDO_USER) would then EACCES on those same + # dirs. `-H` resets HOME to root's home, matching Linux's default + # env_reset behaviour. Harmless on Linux (sudoers already does + # the same), required for hermetic test runs on macOS. + sudo -H ./pkgm.ts i hyperfine test -x /usr/local/bin/hyperfine # HOME override + privilege drop are validated via the pkg cache @@ -208,7 +197,7 @@ jobs: # is the canonical way around the default secure_path policy in # Ubuntu's sudoers; macOS sudo respects the explicit env too. set +e - out=$(sudo env PATH="$root_home/.pkgx/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" ./pkgm.ts i gum 2>&1) + out=$(sudo -H env PATH="$root_home/.pkgx/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" ./pkgm.ts i gum 2>&1) rc=$? set -e echo "$out" From a36e06c04bffa319b0587f417a2117db6dd33816 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 18 May 2026 15:50:58 +0200 Subject: [PATCH 19/22] warn when sudo preserved HOME (macOS shebang/cache footgun) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS sudoers keeps HOME in env_keep by default, so plain \`sudo pkgm install …\` preserves the caller's HOME. The shebang's outer \`pkgx --quiet deno^2.1 run …\` then runs as root with HOME pointing at \$SUDO_USER's tree, and its self-cache creates root-owned dirs under \$SUDO_USER/.pkgx. The privilege-dropped inner pkgx that this PR introduces (sudo -u \$SUDO_USER -- …) then EACCES's on those same dirs and the install dies with Error: Os { code: 13, kind: PermissionDenied, message: "Permission denied" } The CI matrix's macOS leg hit this exact failure mode (see the diagnostic ::warning:: annotations on c700b49/cd3133b) until we switched the test invocation to \`sudo -H\`. A real macOS user typing \`sudo pkgm install …\` will hit the same wall. Detect this state (\`isRoot && SUDO_USER set && HOME == \$SUDO_USER's home\`) and emit a one-line yellow warning pointing the user at \`sudo -H\`. The check is platform-agnostic — any sudoers config that preserves HOME triggers it — but in practice it's a macOS-only path, so the message names that. Also factor the user_home(sudoUser) lookup so the warning and the HOME override share the same call rather than spawning getent/dscl twice. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkgm.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/pkgm.ts b/pkgm.ts index 081702a..27bba54 100755 --- a/pkgm.ts +++ b/pkgm.ts @@ -314,6 +314,23 @@ async function query_pkgx( if (isSystemPrefix) { if (isRoot && sudoUser) { + const sudo_user_home = user_home(sudoUser); + + // Warn if the caller's HOME survived sudo (typical macOS default — + // its sudoers keeps HOME in env_keep; most Linux distros reset it + // via env_reset). When HOME survives, the shebang's outer + // `pkgx --quiet deno^2.1 run …` invocation ran as root with HOME + // pointing at SUDO_USER's tree, so its self-cache likely created + // root-owned dirs under $SUDO_USER/.pkgx. The privilege-dropped + // inner pkgx below would then EACCES on those same dirs. + if (sudo_user_home && Deno.env.get("HOME") === sudo_user_home) { + console.error( + "%cwarning", + "color:yellow", + `\`sudo\` preserved HOME=${sudo_user_home}; pkgx's outer self-cache may have written root-owned dirs there. Re-run as \`sudo -H pkgm …\` if you hit a permission denied error (typical on macOS).`, + ); + } + // Drop privileges so pkgx writes its cache as the invoking user, not root. // But only if pkgx is reachable from sudoUser — otherwise the inner sudo // aborts with "unable to execute …: Permission denied" (pkgxdev/pkgm#68). @@ -323,8 +340,7 @@ async function query_pkgx( cmd_args = ["-u", sudoUser, "--", reachable, ...args]; // Override HOME, or pkgx will cache back under /root/ where sudoUser // can't reach it on the next invocation. - const home = user_home(sudoUser); - if (home) env.HOME = home; + if (sudo_user_home) env.HOME = sudo_user_home; } else if (Deno.env.get("PKGM_DEBUG")) { console.error( `pkgm: \`pkgx\` at ${pkgx} is not reachable as ${sudoUser}; running it as root`, From a09c34e47bf887a91698b03440f21cd806ccdfdd Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 18 May 2026 16:16:19 +0200 Subject: [PATCH 20/22] self-heal macOS sudo cache pollution instead of needing -H MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous commit warned users to retype \`sudo -H pkgm …\` on macOS, where sudoers preserves HOME and the shebang's outer pkgx pollutes \$SUDO_USER/.pkgx with root-owned dirs that the privilege-dropped inner pkgx can't write to. Asking the user to remember a flag breaks the "works the same everywhere" property pkgm should have. Self-heal instead: function reclaim_pkgx_cache_for(home, user) { find \$home/.pkgx -uid 0 -exec chown \$user {} + } Targeted by `-uid 0` so we only touch entries the shebang's outer pkgx left behind — user-owned entries (legitimate cache) are not disturbed. Best-effort: if find/chown aren't reachable the install may still EACCES but in that case `sudo -H` is the manual escape hatch. Wire it into query_pkgx alongside the existing HOME override, gated on the same `HOME === SUDO_USER home` condition that previously emitted the warning. Drop the warning — there's nothing for the user to do now. CI: remove `sudo -H` from the sudo-install test on both legs of the matrix. The macOS leg now exercises the realistic user invocation (plain `sudo pkgm`) and validates that pkgm's self-heal makes it work. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 20 ++++++++------------ pkgm.ts | 38 +++++++++++++++++++++++++++----------- 2 files changed, 35 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f05645..8ac9d5d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -132,17 +132,13 @@ jobs: set -eux # marker to scope checks to entries created by this install touch /tmp/pkgm-sudo-marker - - # Why `sudo -H`: macOS sudoers keeps HOME in env_keep by default, - # so plain `sudo ./pkgm.ts` would preserve HOME=$HOME for the - # shebang's outer `pkgx --quiet deno^2.1 run …` invocation — - # which runs as ROOT and would then write root-owned dirs into - # $HOME/.pkgx for the deno self-cache. The inner pkgx (privilege- - # dropped back to $SUDO_USER) would then EACCES on those same - # dirs. `-H` resets HOME to root's home, matching Linux's default - # env_reset behaviour. Harmless on Linux (sudoers already does - # the same), required for hermetic test runs on macOS. - sudo -H ./pkgm.ts i hyperfine + # Plain `sudo` (no -H). This is what a typical user types. On + # macOS, sudoers keeps HOME in env_keep so the shebang's outer + # pkgx runs as root inside $SUDO_USER's tree and pollutes + # $HOME/.pkgx with root-owned dirs. pkgm.ts's + # reclaim_pkgx_cache_for() chowns those back to $SUDO_USER + # before dropping privileges so the inner pkgx can write. + sudo ./pkgm.ts i hyperfine test -x /usr/local/bin/hyperfine # HOME override + privilege drop are validated via the pkg cache @@ -197,7 +193,7 @@ jobs: # is the canonical way around the default secure_path policy in # Ubuntu's sudoers; macOS sudo respects the explicit env too. set +e - out=$(sudo -H env PATH="$root_home/.pkgx/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" ./pkgm.ts i gum 2>&1) + out=$(sudo env PATH="$root_home/.pkgx/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" ./pkgm.ts i gum 2>&1) rc=$? set -e echo "$out" diff --git a/pkgm.ts b/pkgm.ts index 27bba54..4479e48 100755 --- a/pkgm.ts +++ b/pkgm.ts @@ -316,19 +316,16 @@ async function query_pkgx( if (isRoot && sudoUser) { const sudo_user_home = user_home(sudoUser); - // Warn if the caller's HOME survived sudo (typical macOS default — - // its sudoers keeps HOME in env_keep; most Linux distros reset it - // via env_reset). When HOME survives, the shebang's outer - // `pkgx --quiet deno^2.1 run …` invocation ran as root with HOME - // pointing at SUDO_USER's tree, so its self-cache likely created + // If sudo preserved HOME (typical macOS — sudoers keeps HOME in + // env_keep by default; most Linux distros reset it via env_reset), + // the shebang's outer `pkgx --quiet deno^2.1 run …` ran as root + // with HOME pointing at SUDO_USER's tree and its self-cache left // root-owned dirs under $SUDO_USER/.pkgx. The privilege-dropped - // inner pkgx below would then EACCES on those same dirs. + // inner pkgx below would then EACCES on those dirs and abort the + // install. Reclaim ownership for $SUDO_USER so the install can + // proceed without forcing the user to remember `sudo -H`. if (sudo_user_home && Deno.env.get("HOME") === sudo_user_home) { - console.error( - "%cwarning", - "color:yellow", - `\`sudo\` preserved HOME=${sudo_user_home}; pkgx's outer self-cache may have written root-owned dirs there. Re-run as \`sudo -H pkgm …\` if you hit a permission denied error (typical on macOS).`, - ); + reclaim_pkgx_cache_for(sudo_user_home, sudoUser); } // Drop privileges so pkgx writes its cache as the invoking user, not root. @@ -877,6 +874,25 @@ function user_home(user: string): string | undefined { return user_home_from_passwd(user) ?? user_home_from_dscl(user); } +function reclaim_pkgx_cache_for(home: string, user: string): void { + // Targeted chown: only files currently owned by root, not user-owned + // entries the caller may have placed under .pkgx for their own reasons. + // Best-effort — if find/chown aren't reachable the inner pkgx may still + // EACCES, but most invocations succeed. + const cache = join(home, ".pkgx"); + if (!existsSync(cache)) return; + const find = existsSync("/usr/bin/find") ? "/usr/bin/find" : "/bin/find"; + try { + new Deno.Command(find, { + args: [cache, "-uid", "0", "-exec", "chown", user, "{}", "+"], + stdout: "null", + stderr: "null", + }).outputSync(); + } catch { + // best-effort + } +} + function pkgx_reachable_as(current: string, user: string): string | undefined { // The caller has already enforced PKGX_MIN_VERSION for `current` via // get_pkgx(); fallback candidates have not, so each return path below From 77b78c95d6c6547bddd3174ee351a596767b7461 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 18 May 2026 16:19:48 +0200 Subject: [PATCH 21/22] =?UTF-8?q?ci:=20diagnose=20macOS=20reclaim=20?= =?UTF-8?q?=E2=80=94=20show=20pre/post=20root-owned=20files=20under=20$HOM?= =?UTF-8?q?E/.pkgx?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ac9d5d..f8e1856 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -138,7 +138,12 @@ jobs: # $HOME/.pkgx with root-owned dirs. pkgm.ts's # reclaim_pkgx_cache_for() chowns those back to $SUDO_USER # before dropping privileges so the inner pkgx can write. + echo "::warning::diag pre-pkgx-owners: $(ls -ld $HOME/.pkgx 2>&1 | head -1)" + echo "::warning::diag pre-root-files: $(sudo find $HOME/.pkgx -uid 0 -print 2>/dev/null | head -10 | tr '\n' '|' || echo none)" sudo ./pkgm.ts i hyperfine + echo "::warning::diag post-pkgx-owners: $(ls -ld $HOME/.pkgx 2>&1 | head -1)" + echo "::warning::diag post-root-files: $(sudo find $HOME/.pkgx -uid 0 -print 2>/dev/null | head -10 | tr '\n' '|' || echo none)" + echo "::warning::diag post-root-count: $(sudo find $HOME/.pkgx -uid 0 -print 2>/dev/null | wc -l | tr -d ' ')" test -x /usr/local/bin/hyperfine # HOME override + privilege drop are validated via the pkg cache From 2939be890d3dfc9777e3d7615bfdfe58242a1988 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 18 May 2026 16:21:45 +0200 Subject: [PATCH 22/22] reclaim: chown -h to operate on the symlink, not its target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first cut of reclaim_pkgx_cache_for() invoked \`find -uid 0 -exec chown user {} +\`, which followed symlinks. Pkgx's versioned-layout sprinkles v*, v, v. symlinks beside the real version directories; without -h, chown walked each link to its (already-reclaimed) target, leaving the link itself still root-owned. Diagnostic ::warning:: annotations on 77b78c9 confirmed: 6 entries remained owned by root after the chown — all of them version-alias symlinks under deno.land/* and info-zip.org/unzip/*. Add -h. It's in POSIX, present on both BSD (macOS) and GNU (Linux) chown, and a no-op for regular files. Also drop the diagnostic warnings from ci.yml now that the failure mode is identified. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 5 ----- pkgm.ts | 7 ++++++- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8e1856..8ac9d5d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -138,12 +138,7 @@ jobs: # $HOME/.pkgx with root-owned dirs. pkgm.ts's # reclaim_pkgx_cache_for() chowns those back to $SUDO_USER # before dropping privileges so the inner pkgx can write. - echo "::warning::diag pre-pkgx-owners: $(ls -ld $HOME/.pkgx 2>&1 | head -1)" - echo "::warning::diag pre-root-files: $(sudo find $HOME/.pkgx -uid 0 -print 2>/dev/null | head -10 | tr '\n' '|' || echo none)" sudo ./pkgm.ts i hyperfine - echo "::warning::diag post-pkgx-owners: $(ls -ld $HOME/.pkgx 2>&1 | head -1)" - echo "::warning::diag post-root-files: $(sudo find $HOME/.pkgx -uid 0 -print 2>/dev/null | head -10 | tr '\n' '|' || echo none)" - echo "::warning::diag post-root-count: $(sudo find $HOME/.pkgx -uid 0 -print 2>/dev/null | wc -l | tr -d ' ')" test -x /usr/local/bin/hyperfine # HOME override + privilege drop are validated via the pkg cache diff --git a/pkgm.ts b/pkgm.ts index 4479e48..b9fffbc 100755 --- a/pkgm.ts +++ b/pkgm.ts @@ -883,8 +883,13 @@ function reclaim_pkgx_cache_for(home: string, user: string): void { if (!existsSync(cache)) return; const find = existsSync("/usr/bin/find") ? "/usr/bin/find" : "/bin/find"; try { + // `chown -h`: act on the symlink itself, not its target. Pkgx's + // versioned layout sprinkles v*, v, v. symlinks + // alongside the real version dirs; without -h chown would follow each + // link and chown the already-reclaimed target, leaving the link still + // root-owned (-h is in POSIX, present on both BSD and GNU chown). new Deno.Command(find, { - args: [cache, "-uid", "0", "-exec", "chown", user, "{}", "+"], + args: [cache, "-uid", "0", "-exec", "chown", "-h", user, "{}", "+"], stdout: "null", stderr: "null", }).outputSync();