diff --git a/bin/omarchy-agent-usage-codex b/bin/omarchy-agent-usage-codex index 972c164dee9..b2413413922 100755 --- a/bin/omarchy-agent-usage-codex +++ b/bin/omarchy-agent-usage-codex @@ -91,6 +91,66 @@ def find_command(name): return shutil.which(name, path=ENV.get("PATH")) +def mise_shims_dir(): + data_home = ENV.get("MISE_DATA_DIR") or os.path.join( + ENV.get("XDG_DATA_HOME") or os.path.join(str(Path.home()), ".local", "share"), "mise") + return os.path.join(data_home, "shims") + + +def is_lazy_launcher(path): + # omarchy-mise-install writes a regular file whose first step is `mise use + # -g`, and mise's shims exec `mise x`; running either installs the tool it + # wraps. A shim is a symlink to the mise binary itself, so the symlink + # check comes after the shim checks: a symlink elsewhere is the user's own + # binary. + if not path: + return False + shims = mise_shims_dir() + if os.path.dirname(os.path.abspath(path)) == shims: + return True + if os.path.islink(path): + mise = find_command("mise") + if mise: + try: + if os.path.realpath(path) == os.path.realpath(mise): + return True + except OSError: + pass + return False + try: + with open(path, "rb") as handle: + head = handle.read(4096) + return b"mise use" in head or b"mise x " in head + except OSError: + return False + + +def find_codex_binary(): + for directory in ENV.get("PATH", "").split(os.pathsep): + candidate = os.path.join(directory, "codex") + if os.path.isfile(candidate) and os.access(candidate, os.X_OK) and not is_lazy_launcher(candidate): + return candidate + # `mise which` only prints a binary that is already installed, so it is the + # safe way to resolve a codex managed by mise without running the launcher. + mise = find_command("mise") + if mise and not is_lazy_launcher(mise): + try: + # `mise which` can resolve a configured `latest` over the network; the + # usage probe stays offline. + env = dict(ENV, MISE_OFFLINE="1") + out = subprocess.run( + [mise, "which", "codex"], capture_output=True, text=True, env=env, timeout=10 + ) + if out.returncode == 0: + for line in reversed(out.stdout.strip().splitlines()): + resolved = line.strip() + if resolved and os.path.isfile(resolved) and not is_lazy_launcher(resolved): + return resolved + except Exception: + pass + return None + + now = datetime.now() today = now.strftime("%Y-%m-%d") recent_dates = [(now - timedelta(days=offset)).strftime("%Y-%m-%d") for offset in range(6, -1, -1)] @@ -520,7 +580,7 @@ def limit_window(window): def fetch_codex_rpc(): result = {"limits": [], "tierLabel": "", "usageStatusText": "", "authHelpText": AUTH_HELP} - codex = find_command("codex") + codex = find_codex_binary() if not codex: result["usageStatusText"] = "Codex unavailable" result["authHelpText"] = "codex not found in PATH" diff --git a/test/shell.d/agent-usage-codex-scanner-test.sh b/test/shell.d/agent-usage-codex-scanner-test.sh index 3ce4883cdbf..3bf9801f122 100644 --- a/test/shell.d/agent-usage-codex-scanner-test.sh +++ b/test/shell.d/agent-usage-codex-scanner-test.sh @@ -603,3 +603,103 @@ result=$(HOME="$INTERRUPTED_HOME" CODEX_HOME="$INTERRUPTED_HOME/.codex" XDG_CACH [[ $(jq -r '.todayTotalTokens' <<<"$result") == "9" ]] || fail "Codex collector does not reuse a snapshot from an interrupted scan" "$result" pass "Codex collector does not cache an interrupted opencode scan" + +# The lazy launcher at ~/.local/bin/codex runs `mise use -g` when executed, so +# a read-only usage probe on a machine without Codex must never spawn it. A +# private tools dir keeps a real codex or mise on the host out of the probe: +# PATH holds only the interpreter and file tools the collector may exec. +LAUNCH_HOME=$(mktemp -d) +SAFE_PATH="$LAUNCH_HOME/tools" +mkdir -p "$SAFE_PATH" +for tool in python3 rg; do + if command -v "$tool" >/dev/null; then + ln -s "$(command -v "$tool")" "$SAFE_PATH/$tool" + fi +done +trap 'rm -rf "$TEST_HOME" "$PI_HOME" "$OPENCODE_HOME" "$CACHE_HOME" "$FRESH_HOME" "$MALFORMED_HOME" "$UNWRITABLE_HOME" "$INTERRUPTED_HOME" "$LAUNCH_HOME"' EXIT +mkdir -p "$LAUNCH_HOME/bin" "$LAUNCH_HOME/.local/bin" +cat >"$LAUNCH_HOME/.local/bin/codex" <<'LAUNCHER' +#!/bin/bash +export MISE_MINIMUM_RELEASE_AGE=0 +mise use -g --quiet "npm:@openai/codex" || exit 1 +exec mise x "npm:@openai/codex" -- codex "$@" +LAUNCHER +chmod +x "$LAUNCH_HOME/.local/bin/codex" +cat >"$LAUNCH_HOME/bin/mise" <<'STUB' +#!/bin/bash +printf '%s\n' "$*" >>"$MISE_CALLS_FILE" +exit 1 +STUB +chmod +x "$LAUNCH_HOME/bin/mise" + +result=$(HOME="$LAUNCH_HOME" CODEX_HOME="$LAUNCH_HOME/.codex" MISE_CALLS_FILE="$LAUNCH_HOME/mise-calls" XDG_DATA_HOME="$LAUNCH_HOME/.local/share" \ + PATH="$LAUNCH_HOME/bin:$SAFE_PATH" "$ROOT/bin/omarchy-agent-usage-codex" --limits-only) + +[[ $(jq -r '.usageStatusText' <<<"$result") == "Codex unavailable" ]] || + fail "Codex collector reports Codex unavailable when only the launcher exists" "$result" +# The launcher would log `use -g ...` through the mise stub if it ever ran; +# `which codex` is the only permitted call. +[[ ! -s $LAUNCH_HOME/mise-calls || $(cat "$LAUNCH_HOME/mise-calls") == "which codex" ]] || + fail "Codex collector must not execute the lazy codex launcher" "$(cat "$LAUNCH_HOME/mise-calls" 2>/dev/null)" +pass "Codex collector resolves through mise which instead of running the launcher" + +# When mise reports an installed binary, that binary is probed, not the +# launcher that shadows it on PATH. +cp "$TEST_HOME/bin/codex" "$LAUNCH_HOME/real-codex" +cat >"$LAUNCH_HOME/bin/mise" <>"$LAUNCH_HOME/mise-calls" +echo "$LAUNCH_HOME/real-codex" +STUB + +rm -f "$LAUNCH_HOME/mise-calls" +result=$(HOME="$LAUNCH_HOME" CODEX_HOME="$LAUNCH_HOME/.codex" CODEX_ARGS_FILE="$LAUNCH_HOME/codex-args" XDG_DATA_HOME="$LAUNCH_HOME/.local/share" \ + PATH="$LAUNCH_HOME/bin:$SAFE_PATH" "$ROOT/bin/omarchy-agent-usage-codex" --limits-only) + +[[ -f $LAUNCH_HOME/codex-args && $(cat "$LAUNCH_HOME/mise-calls") == "which codex" ]] || + fail "Codex collector probes the binary mise which reports" "$result" +pass "Codex collector probes the mise-resolved binary" + +# A symlink at the launcher path is the user's own binary, so it is probed +# directly without asking mise at all. +LINK_HOME=$(mktemp -d) +trap 'rm -rf "$TEST_HOME" "$PI_HOME" "$OPENCODE_HOME" "$CACHE_HOME" "$FRESH_HOME" "$MALFORMED_HOME" "$UNWRITABLE_HOME" "$INTERRUPTED_HOME" "$LAUNCH_HOME" "$LINK_HOME"' EXIT +mkdir -p "$LINK_HOME/bin" "$LINK_HOME/.local/bin" +ln -s "$TEST_HOME/bin/codex" "$LINK_HOME/.local/bin/codex" +cat >"$LINK_HOME/bin/mise" <>"$LINK_HOME/mise-calls" +echo "$LINK_HOME/real-codex" +STUB +chmod +x "$LINK_HOME/bin/mise" + +result=$(HOME="$LINK_HOME" CODEX_HOME="$LINK_HOME/.codex" CODEX_ARGS_FILE="$LINK_HOME/codex-args" XDG_DATA_HOME="$LINK_HOME/.local/share" \ + PATH="$LINK_HOME/bin:$SAFE_PATH" "$ROOT/bin/omarchy-agent-usage-codex" --limits-only) + +[[ -f $LINK_HOME/codex-args && ! -s $LINK_HOME/mise-calls ]] || + fail "Codex collector probes a symlinked codex without invoking mise" "$result" +pass "Codex collector probes a user-owned symlink at the launcher path" + +# A mise shim is a symlink to the mise binary itself, so it resolves to mise, +# not to an installed codex — running it would exec `mise x` and install the +# tool. It must be treated as lazy despite being a symlink. +SHIM_HOME=$(mktemp -d) +trap 'rm -rf "$TEST_HOME" "$PI_HOME" "$OPENCODE_HOME" "$CACHE_HOME" "$FRESH_HOME" "$MALFORMED_HOME" "$UNWRITABLE_HOME" "$INTERRUPTED_HOME" "$LAUNCH_HOME" "$LINK_HOME" "$SHIM_HOME"' EXIT +mkdir -p "$SHIM_HOME/bin" "$SHIM_HOME/.local/share/mise/shims" +cat >"$SHIM_HOME/bin/mise" <>"$SHIM_HOME/mise-calls" +exit 1 +STUB +chmod +x "$SHIM_HOME/bin/mise" +ln -s "$SHIM_HOME/bin/mise" "$SHIM_HOME/.local/share/mise/shims/codex" + +result=$(HOME="$SHIM_HOME" CODEX_HOME="$SHIM_HOME/.codex" MISE_CALLS_FILE="$SHIM_HOME/mise-calls" XDG_DATA_HOME="$SHIM_HOME/.local/share" \ + PATH="$SHIM_HOME/bin:$SAFE_PATH" "$ROOT/bin/omarchy-agent-usage-codex" --limits-only) + +[[ $(jq -r '.usageStatusText' <<<"$result") == "Codex unavailable" ]] || + fail "Codex collector reports Codex unavailable when only a mise shim exists" "$result" +[[ ! -s $SHIM_HOME/mise-calls || $(cat "$SHIM_HOME/mise-calls") == "which codex" ]] || + fail "Codex collector must not execute a mise shim" "$(cat "$SHIM_HOME/mise-calls" 2>/dev/null)" +pass "Codex collector treats a shim symlink to mise as lazy" +