From 6e8e4a2f38c0da78eb27a9fbfa16c527c00fb307 Mon Sep 17 00:00:00 2001 From: Chai Pin Zheng Date: Sat, 17 Oct 2026 17:52:19 +0800 Subject: [PATCH 1/5] cli(fix): patch status, launcher, lock, workspace and upgrade defects - status --json stays valid when a profile fails or the CLI is missing, validates names, and plain status stops once without a CLI. - launcher create checks every build step, fits default names to any profile, handles case-only renames and cleans up on interrupt. - remove refuses a differently cased spelling of a stored profile. - unset CDPATH; anchor relative workspace paths; unbind resolves a surviving parent; app passes ./-dash workspaces to open; app-instance without a profile opens the picker. - stale-lock reclaim claims the owner file by rename; doctor --json reports unreadable state; env refuses symlinked homes. - prompt hook drops stale indicators; upgrade anchors relative prefixes and refuses non-commit refs; zsh completion works under any autoload name; doctor completes -j; clone-config refuses more secret shapes; help lists update-check variables. Co-Authored-By: Claude Opus 5.5 --- bin/codex-profile | 190 ++++++++++++++++++++++++++--------- test/cli/completions-test.sh | 32 ++++++ test/cli/desktop-test.sh | 23 +++++ test/cli/help-test.sh | 2 +- test/cli/interactive-test.sh | 2 +- test/cli/launcher-test.sh | 63 ++++++++++++ test/cli/profiles-test.sh | 35 ++++++- test/cli/prompt-test.sh | 33 ++++++ test/cli/shell-test.sh | 11 ++ test/cli/status-json-test.sh | 66 ++++++++++++ test/cli/upgrade-test.sh | 32 ++++++ test/cli/workspace-test.sh | 74 ++++++++++++++ 12 files changed, 506 insertions(+), 57 deletions(-) diff --git a/bin/codex-profile b/bin/codex-profile index e6649cb..a8bfe30 100755 --- a/bin/codex-profile +++ b/bin/codex-profile @@ -1,6 +1,9 @@ #!/usr/bin/env bash set -euo pipefail +# An exported CDPATH makes `cd` search elsewhere and print the directory, +# which would corrupt every captured path. +unset CDPATH PROGRAM="${0##*/}" VERSION="1.3.0" @@ -151,14 +154,14 @@ $PROGRAM init work-2 --share-with work" ;; clone-config) syntax='clone-config [--force]' - description='Copy only config.toml and AGENTS.md between initialized profiles. Refuses sensitive-looking keys and symlinks; authentication data is never copied.' + description='Copy only config.toml and AGENTS.md between initialized profiles. Refuses files with sensitive-looking text and symlinks; authentication data is never copied.' options='--force, -f | Overwrite existing safe target configuration files.' examples="$PROGRAM clone-config personal work $PROGRAM clone-config personal work --force" ;; remove) syntax='remove [--yes]' - description='Permanently delete a profile home and its workspace bindings. Prompts for the profile name unless --yes is supplied. Shared configuration dependants must be detached first.' + description='Permanently delete a profile home and its workspace bindings. Prompts for the profile name unless --yes is supplied. Shared configuration dependants must be detached and a managed launcher removed first.' options='--yes, -y | Skip the confirmation prompt.' examples="$PROGRAM remove work" ;; @@ -269,7 +272,7 @@ $PROGRAM status --json work" ;; usage) syntax='usage [--json] [profile...]' - description='Show remaining Codex CLI quota for the named profiles, or for every initialized profile. Reads use the official Codex app-server without reading credentials or sending inference requests, and wait up to CODEX_PROFILE_USAGE_TIMEOUT seconds (default 10) per profile. Returns nonzero when a named profile cannot be read. ChatGPT may use a different account.' + description='Show remaining Codex CLI quota for the named profiles, or for every initialized profile. Reads use the official Codex app-server without reading credentials or sending inference requests, and wait up to CODEX_PROFILE_USAGE_TIMEOUT seconds (default 10) per profile. Returns nonzero when the Codex CLI is missing or a named profile cannot be read. ChatGPT may use a different account.' options='--json, -j | Emit machine-readable JSON. -- | Treat remaining arguments as profile names.' examples="$PROGRAM usage @@ -469,6 +472,9 @@ CODEX_PROFILE_UPGRADE_RELEASE_URL | Latest-release metadata URL CODEX_PROFILE_UPGRADE_CACHE | Upgrade cache checkout CODEX_PROFILE_UPGRADE_PREFIX | Upgrade install prefix CODEX_PROFILE_NO_UPDATE_CHECK | Disable daily checks (or DO_NOT_TRACK) +CODEX_PROFILE_UPDATE_INTERVAL | Seconds between checks; default: 86400 +CODEX_PROFILE_UPDATE_CACHE | Update-check state file +CODEX_PROFILE_UPDATE_URL | Update-check version source NO_COLOR | Disable help colors Deprecated compatibility: @@ -891,6 +897,8 @@ canonical_directory() { workspace_path_has_control_characters "$path" && \ die "Workspace paths cannot contain tab, carriage-return, or newline control characters." [[ -d "$path" ]] || die "Workspace directory does not exist: $path" + # A bare `-` would otherwise mean the previous directory. + [[ "$path" == /* ]] || path="./$path" canonical="$(cd -- "$path" 2> /dev/null && pwd -P)" || \ die "Cannot resolve workspace directory: $path" workspace_path_has_control_characters "$canonical" && \ @@ -913,6 +921,15 @@ workspace_path_for_unbind() { while [[ "$path" != "/" && "$path" == */ ]]; do path="${path%/}" done + + # Bindings record resolved paths, so resolve a parent that still exists + # (for example /tmp -> /private/tmp or a symlinked projects folder). + local parent="${path%/*}" name="${path##*/}" resolved + if [[ "$name" != "." && "$name" != ".." && -d "${parent:-/}" ]] && \ + resolved="$(cd -- "${parent:-/}" 2> /dev/null && pwd -P)" && \ + ! workspace_path_has_control_characters "$resolved"; then + path="${resolved%/}/$name" + fi printf '%s\n' "$path" } @@ -1006,6 +1023,22 @@ state_lock_release() { STATE_LOCK_HELD=no } +# Two waiters can both find the same dead owner. Renaming the owner file lets +# only one of them claim it, and a file that turns out to name a newer, live +# owner (who reclaimed the lock in between) is put back untouched. +state_lock_reclaim_stale() { + local stale_owner="$1" claimed="$STATE_LOCK_DIR/pid.reclaim.$$" owner="" + + mv -f -- "$STATE_LOCK_DIR/pid" "$claimed" 2> /dev/null || return 0 + IFS= read -r owner < "$claimed" || owner="" + if [[ "$owner" == "$stale_owner" ]]; then + rm -f -- "$claimed" 2> /dev/null || true + rmdir "$STATE_LOCK_DIR" 2> /dev/null || true + else + mv -f -- "$claimed" "$STATE_LOCK_DIR/pid" 2> /dev/null || true + fi +} + state_lock_acquire() { local attempts=0 max_attempts owner="" @@ -1023,13 +1056,15 @@ state_lock_acquire() { fi if [[ "$owner" =~ ^[1-9][0-9]*$ ]] && \ ! kill -0 "$owner" 2> /dev/null && ! ps -p "$owner" > /dev/null 2>&1; then - rm -f -- "$STATE_LOCK_DIR/pid" 2> /dev/null || true - rmdir "$STATE_LOCK_DIR" 2> /dev/null || true + state_lock_reclaim_stale "$owner" continue fi attempts=$((attempts + 1)) if ((attempts >= max_attempts)); then - die "Timed out waiting for state mutation lock${owner:+ held by process $owner}: $STATE_LOCK_DIR" + if [[ -n "$owner" ]]; then + die "Timed out waiting for state mutation lock held by process $owner: $STATE_LOCK_DIR" + fi + die "Timed out waiting for state mutation lock: $STATE_LOCK_DIR. If no $PROGRAM command is running, remove that directory and try again." fi sleep 1 done @@ -1095,7 +1130,8 @@ workspace_guard_mode() { return 0 fi - mode="$(< "$WORKSPACE_GUARD_FILE")" + mode="$(cat -- "$WORKSPACE_GUARD_FILE" 2> /dev/null)" || \ + die "Cannot read workspace guard state: $WORKSPACE_GUARD_FILE" case "$mode" in off | warn | strict) printf '%s\n' "$mode" @@ -1278,7 +1314,8 @@ workspace_registry_stats() { fi if [[ -f "$WORKSPACE_GUARD_FILE" ]]; then - mode="$(< "$WORKSPACE_GUARD_FILE")" + # Diagnostics report unreadable state instead of exiting on it. + mode="$(cat -- "$WORKSPACE_GUARD_FILE" 2> /dev/null)" || mode="" case "$mode" in off | warn | strict) WORKSPACE_STATS_GUARD_MODE="$mode" ;; *) @@ -1289,6 +1326,10 @@ workspace_registry_stats() { fi [[ -f "$WORKSPACE_REGISTRY" ]] || return 0 + if [[ ! -r "$WORKSPACE_REGISTRY" ]]; then + WORKSPACE_STATS_REGISTRY_VALID=false + return 0 + fi while IFS= read -r line || [[ -n "$line" ]]; do line_number=$((line_number + 1)) [[ -n "$line" ]] || continue @@ -1584,12 +1625,6 @@ command_app() { local instance=no rebuild=no local profile="" workspace="" profile_set=no workspace_set=no positional_only=no - if [[ "$#" -eq 0 ]]; then - pick_profile || return $? - profile="$PICKED_PROFILE" - profile_set=yes - fi - while [[ "$#" -gt 0 ]]; do if [[ "$positional_only" == no ]]; then case "$1" in @@ -1626,7 +1661,12 @@ command_app() { shift done - [[ "$profile_set" == yes ]] || die "$usage" + # Compatibility flags alone (as app-instance passes) still choose interactively. + if [[ "$profile_set" == no ]]; then + pick_profile || return $? + profile="$PICKED_PROFILE" + profile_set=yes + fi [[ "$workspace_set" == yes ]] || workspace="$PWD" require_initialized_profile "$profile" workspace_guard_profile "$profile" "$workspace" @@ -1683,6 +1723,8 @@ run_desktop_app() { note "Electron user data: $user_data_dir" fi note "Log: $log_file" + # open(1) has no `--`; a relative workspace such as -h would be an option. + [[ "$workspace" == /* ]] || workspace="./$workspace" if [[ "$profile" == "default" ]]; then (umask 077 && open -n --env "CODEX_HOME=$codex_home" --env "CODEX_ELECTRON_USER_DATA_PATH=" --stdout "$log_file" --stderr "$log_file" -a "$app" "$workspace") \ @@ -1912,18 +1954,19 @@ launcher_build_bundle() { local bundle="$1" profile="$2" name="$3" color="$4" local app source_icon color_hex bundle_id program_path escaped_name escaped_bundle_id - app="$(find_desktop_app)" + # Callers run this in a condition, where errexit is off: check every step. + app="$(find_desktop_app)" || return 1 source_icon="$(launcher_find_source_icon "$app")" || die "ChatGPT app icon not found in: $app" color_hex="$(launcher_color_hex "$color")" || die "Unsupported launcher color '$color'. Use blue, green, teal, purple, pink, red, orange, or graphite." - bundle_id="$(launcher_bundle_identifier "$profile")" - program_path="$(current_program_path)" + bundle_id="$(launcher_bundle_identifier "$profile")" || return 1 + program_path="$(current_program_path)" || return 1 escaped_name="$(xml_escape "$name")" escaped_bundle_id="$(xml_escape "$bundle_id")" - mkdir -p "$bundle/Contents/MacOS" "$bundle/Contents/Resources/codex-profile" - cat > "$bundle/Contents/Info.plist" < "$bundle/Contents/Info.plist" < @@ -1944,11 +1987,11 @@ EOF printf '#!/bin/bash\n' # shellcheck disable=SC2016 # the generated launcher expands $HOME at click time printf 'exec %s app %s "$HOME"\n' "$(shell_squote "$program_path")" "$(shell_squote "$profile")" - } > "$bundle/Contents/MacOS/launch-profile" - chmod 755 "$bundle/Contents/MacOS/launch-profile" - printf '%s\n' "$profile" > "$bundle/Contents/Resources/codex-profile/profile" - printf '%s\n' "$name" > "$bundle/Contents/Resources/codex-profile/name" - printf '%s\n' "$color" > "$bundle/Contents/Resources/codex-profile/color" + } > "$bundle/Contents/MacOS/launch-profile" || return 1 + chmod 755 "$bundle/Contents/MacOS/launch-profile" || return 1 + printf '%s\n' "$profile" > "$bundle/Contents/Resources/codex-profile/profile" || return 1 + printf '%s\n' "$name" > "$bundle/Contents/Resources/codex-profile/name" || return 1 + printf '%s\n' "$color" > "$bundle/Contents/Resources/codex-profile/color" || return 1 launcher_render_icon "$source_icon" "$color_hex" "$bundle/Contents/Resources/AppIcon.icns" } @@ -1976,7 +2019,14 @@ command_launcher_create() { esac shift done - [[ -n "$name" ]] || name="ChatGPT $profile" + if [[ -z "$name" ]]; then + # Profile names may be long or end in punctuation; launcher names may not. + name="ChatGPT $profile" + name="${name:0:80}" + while [[ "$name" =~ [^A-Za-z0-9]$ ]]; do + name="${name%?}" + done + fi validate_launcher_name "$name" launcher_color_hex "$color" > /dev/null || die "Unsupported launcher color '$color'. Use blue, green, teal, purple, pink, red, orange, or graphite." @@ -2016,10 +2066,19 @@ command_launcher_create() { temp="$(mktemp -d "$CODEX_PROFILE_LAUNCHER_ROOT/.codex-profile-launcher.XXXXXX")" || die "Cannot create temporary launcher bundle." + # An interrupt must not leave a half-built bundle in the launcher root. + trap 'rm -rf "$temp"; state_lock_release; exit 1' HUP INT TERM if ! (launcher_build_bundle "$temp/$name.app" "$profile" "$name" "$color"); then rm -rf "$temp" die "Cannot build launcher for profile '$profile'." fi + trap 'state_lock_release; exit 1' HUP INT TERM + + # A rename that only changes letter case names the same bundle on a + # case-insensitive volume; it is staged once, as the current path. + if [[ -n "$old_path" && "$old_path" != "$path" && "$old_path" -ef "$path" ]]; then + old_path="$path" + fi if [[ -e "$path" || -L "$path" ]]; then backup="$CODEX_PROFILE_LAUNCHER_ROOT/.codex-profile-backup.$$.app" @@ -2717,8 +2776,18 @@ command_remove() { shift done - local codex_home confirmation + local codex_home confirmation stored codex_home="$(codex_home_for_profile "$profile")" + # On a case-insensitive volume Work opens work's home, but dependent links + # and bindings record the stored spelling, so checks need that exact name. + if [[ -d "$codex_home" ]]; then + for stored in $(discover_profiles yes); do + [[ "$stored" != "$profile" ]] || break + if [[ "$(codex_home_for_profile "$stored")" -ef "$codex_home" ]]; then + die "Profile '$profile' is stored as '$stored'; run '$PROGRAM remove $stored' instead." + fi + done + fi workspace_registry_validate profile_assert_removable "$profile" @@ -2776,7 +2845,8 @@ command_status_one() { return 0 fi - codex_cli="$(find_codex_cli)" + # find_codex_cli reports the failure; stop once instead of once per profile. + codex_cli="$(find_codex_cli)" || exit 1 printf '%s (%s): ' "$profile" "$codex_home" @@ -2904,10 +2974,8 @@ json_emit_status_profiles_array() { printf '[' if [[ -n "$requested_profile" ]]; then - set +e - object="$(json_status_object "$requested_profile")" - object_status=$? - set -e + object_status=0 + object="$(json_status_object "$requested_profile")" || object_status=$? printf '%s' "$object" printf ']' return "$object_status" @@ -2920,10 +2988,8 @@ json_emit_status_profiles_array() { printf ',' fi - set +e - object="$(json_status_object "$profile")" - object_status=$? - set -e + object_status=0 + object="$(json_status_object "$profile")" || object_status=$? printf '%s' "$object" if [[ "$object_status" -ne 0 ]]; then @@ -2940,10 +3006,8 @@ command_status_json() { local status_code=0 printf '{"profiles":' - set +e - json_emit_status_profiles_array "$profile" - status_code=$? - set -e + # Called as a condition so a failing profile cannot stop the closing brace. + json_emit_status_profiles_array "$profile" || status_code=$? printf '}\n' return "$status_code" @@ -2964,6 +3028,7 @@ command_status() { ;; *) [[ -z "$profile" ]] || die "Usage: $PROGRAM status [--json] [profile]" + validate_profile "$1" profile="$1" ;; esac @@ -3843,6 +3908,8 @@ command_env() { codex_home="$(codex_home_for_profile "$profile")" workspace_guard_profile "$profile" "$PWD" + # Like every other command, never select a home that is another profile's. + [[ ! -L "$codex_home" ]] || die "Refusing symlinked profile home: $codex_home" if [[ ! -d "$codex_home" ]]; then printf "%s: profile '%s' is not initialized (%s); run '%s init %s'.\n" \ "$PROGRAM" "$profile" "$codex_home" "$PROGRAM" "$profile" >&2 @@ -3944,7 +4011,11 @@ command_logs() { config_file_looks_sensitive() { local file="$1" - grep -Eiq '(auth|token|secret|password|passwd|credential|api[_-]?key|access[_-]?key|private[_-]?key|oauth|bearer)' "$file" + # Matches anywhere in the file, prose included: refusing is the safe miss. + grep -Eiq '(auth|token|secret|password|passwd|credential|api[_-]?key|access[_-]?key|private[_-]?key|oauth|bearer|cookie)' "$file" || + grep -Eiq '([_-]key|[_-]pat|(^|[^a-z0-9])(key|dsn))([^a-z0-9]|$)' "$file" || + grep -Eiq '[a-z][a-z0-9+.-]*://[^/[:space:]@]+:[^/[:space:]@]*@' "$file" || + grep -Eiq '(^|[^a-z0-9])(sk|ghp|gho|ghs|ghu|github_pat|glpat|xox[abpors])[_-][a-z0-9]' "$file" } command_clone_config() { @@ -4002,7 +4073,7 @@ command_clone_config() { fi if config_file_looks_sensitive "$source_file"; then - note "Refusing to copy $file because it contains sensitive-looking keys." + note "Refusing to copy $file because it contains sensitive-looking text." failed=1 continue fi @@ -4274,7 +4345,11 @@ upgrade_checkout_ref() { elif git -C "$cache" show-ref --verify --quiet "refs/remotes/origin/$ref"; then git -C "$cache" checkout --quiet -B "$ref" "origin/$ref" else - git -C "$cache" checkout --quiet "$ref" + # Without the check and `--`, a ref spelled like a path (bin, docs) would + # restore that path and install whatever was already checked out. + git -C "$cache" rev-parse --verify --quiet "$ref^{commit}" > /dev/null \ + || die "Upgrade ref does not exist: $ref" + git -C "$cache" checkout --quiet "$ref" -- fi } @@ -4345,6 +4420,8 @@ command_upgrade() { [[ -n "$prefix" ]] || die "Upgrade prefix cannot be empty." [[ "$repo" != -* ]] || die "Upgrade repository must not start with '-'." [[ "$ref" != -* ]] || die "Upgrade ref must not start with '-'." + # make runs in the cache, so a relative prefix must be anchored here first. + [[ "$prefix" == /* ]] || prefix="$PWD/$prefix" requested_ref="$ref" if [[ "$ref" == "latest" ]]; then @@ -4550,7 +4627,7 @@ _codex_profile_candidates() { esac fi ;; - doctor) candidate_words='--json --check' ;; + doctor) candidate_words='--json -j --check' ;; completions | shell-init) if [[ "$#" -eq 0 ]]; then candidate_words='bash zsh fish' elif [[ "$command" == shell-init ]]; then candidate_words='--prompt --completions' @@ -4607,7 +4684,10 @@ _codex_profile() { return 0 } -if [[ "$ZSH_EVAL_CONTEXT" == *:loadautofunc ]]; then +# Autoloaded under any file name (such as _codex-profile), this body runs on +# every completion, not only the first: complete each time instead of +# re-registering. +if [[ "$ZSH_EVAL_CONTEXT" == *:loadautofunc || "$ZSH_EVAL_CONTEXT" == *:shfunc ]]; then _codex_profile "$@" elif (( $+functions[compdef] )); then compdef _codex_profile codex-profile codex-profiles @@ -4782,7 +4862,7 @@ function __codex_profile_complete end end case doctor - set candidates --json --check + set candidates --json -j --check case completions shell-init if test $count -eq 0 set candidates bash zsh fish @@ -4832,10 +4912,20 @@ emit_shell_prompt() { bash | zsh) cat <<'EOF' __codex_profile_update_prompt() { - local __cp_status=$? __cp_home="${HOME:-}/.codex" - if [[ -n "${__codex_profile_prefix:-}" && "${PS1:-}" == "$__codex_profile_prefix"* ]]; then - PS1="${PS1#"$__codex_profile_prefix"}" - fi + local __cp_status=$? __cp_home="${HOME:-}/.codex" __cp_kept='' __cp_rest="${PS1:-}" __cp_name + # Tools such as Python venv save and restore PS1 with an earlier indicator + # inside it, so remove every [codex:] indicator, not just a leading one. + while [[ "$__cp_rest" == *'[codex:'* ]]; do + __cp_kept+="${__cp_rest%%\[codex:*}" + __cp_rest="${__cp_rest#*\[codex:}" + __cp_name="${__cp_rest%%\] *}" + if [[ "$__cp_rest" == *'] '* && "$__cp_name" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then + __cp_rest="${__cp_rest#*\] }" + else + __cp_kept+='[codex:' + fi + done + PS1="$__cp_kept$__cp_rest" __codex_profile_prefix='' if [[ -n "${HOME:-}" && "${CODEX_PROFILE_NAME:-}" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then [[ "$CODEX_PROFILE_NAME" == default ]] || __cp_home="$HOME/.codex-$CODEX_PROFILE_NAME" diff --git a/test/cli/completions-test.sh b/test/cli/completions-test.sh index d1c63fb..cec08c2 100644 --- a/test/cli/completions-test.sh +++ b/test/cli/completions-test.sh @@ -146,6 +146,10 @@ for shell in bash zsh fish; do assert_contains --ref complete_words upgrade --ref '' assert_not_contains --prefix + complete_words doctor - + assert_contains --json + assert_contains --check + grep -Fqx -- -j <<< "$output" || fail "$shell doctor completion lacks -j: $output" complete_words shell-init zsh --prompt --c assert_contains --completions complete_words shell-init bash --completions --p @@ -180,6 +184,34 @@ if command -v zsh > /dev/null 2>&1; then ' assert_status 0 assert_equals client + + # The documented file name differs from the function name; its body then + # runs on every completion and must complete each time. + mkdir -p "$TMP_ROOT/zfunc-dash" + cp "$TMP_ROOT/completions.zsh" "$TMP_ROOT/zfunc-dash/_codex-profile" + # shellcheck disable=SC2016 # the child zsh evaluates its completion context + run_cmd env HOME="$TMP_ROOT/home" PATH="$TMP_ROOT/bin:$PATH" ZFUNC="$TMP_ROOT/zfunc-dash" zsh -f -c ' + fpath=("$ZFUNC" $fpath) + autoload -Uz _codex-profile + _describe() { printf "%s\n" "${(@P)2}"; } + words=(codex-profile app "") + CURRENT=3 + _codex-profile + _codex-profile + _codex-profile + ' + assert_status 0 + assert_equals $'client\nclient\nclient' + + # Sourcing the output at the top level still registers it with compsys. + # shellcheck disable=SC2016 # the child zsh evaluates its completion context + run_cmd env HOME="$TMP_ROOT/home" PATH="$TMP_ROOT/bin:$PATH" COMPLETIONS="$TMP_ROOT/completions.zsh" zsh -f -c ' + autoload -Uz compinit && compinit -u -D + source "$COMPLETIONS" + printf "%s\n" "${_comps[codex-profile]}" + ' + assert_status 0 + assert_equals _codex_profile fi # Candidate arrays cannot prove Readline quoting or directory suffix behavior. diff --git a/test/cli/desktop-test.sh b/test/cli/desktop-test.sh index 06ed566..a45581c 100755 --- a/test/cli/desktop-test.sh +++ b/test/cli/desktop-test.sh @@ -153,6 +153,28 @@ test_app_rebuild_is_accepted_as_a_compatibility_noop() { rm -rf "$tmp" } +test_app_never_passes_a_workspace_as_an_open_option() { + local tmp chatgpt_app fake_bin tool_log + tmp="$(mktemp -d)" + chatgpt_app="$tmp/ChatGPT.app" + fake_bin="$tmp/bin" + tool_log="$tmp/tool.log" + write_fake_chatgpt_app_bundle "$chatgpt_app" "dash workspace launch" + write_fake_chatgpt_open_tools "$fake_bin" + mkdir -p "$tmp/home/.codex-work" "$tmp/-h" + + set +e + output="$(cd "$tmp" && env HOME="$tmp/home" PATH="$fake_bin:$PATH" FAKE_TOOL_LOG="$tool_log" \ + CHATGPT_APP="$chatgpt_app" "$SCRIPT" app work -- -h 2>&1)" + status=$? + set -e + + assert_status 0 + grep -Fq "files=./-h " "$tool_log" || fail "a dash-leading workspace reached open as an option: $(cat "$tool_log")" + + rm -rf "$tmp" +} + test_cli_falls_back_to_the_bundled_cli_when_path_codex_is_broken() { local tmp chatgpt_app fake_bin broken_codex tmp="$(mktemp -d)" @@ -509,6 +531,7 @@ test_app_named_profile_uses_separate_local_state_for_the_whole_chatgpt_window test_app_default_reuses_the_stock_chatgpt_session test_app_legacy_instance_flags_use_the_same_signed_app_launcher test_app_rebuild_is_accepted_as_a_compatibility_noop +test_app_never_passes_a_workspace_as_an_open_option test_cli_falls_back_to_the_bundled_cli_when_path_codex_is_broken test_healthy_path_cli_keeps_priority_over_the_bundled_cli test_legacy_codex_app_bin_locates_its_signed_app_bundle diff --git a/test/cli/help-test.sh b/test/cli/help-test.sh index 9ce8bc1..3b6f333 100644 --- a/test/cli/help-test.sh +++ b/test/cli/help-test.sh @@ -24,7 +24,7 @@ FORBIDDEN_CODEX chmod 755 "$TMP_ROOT/forbidden-codex" for topic in app app-instance cli login run setup init detach clone-config remove list path \ - workspace launcher status doctor logs upgrade version env use completions shell-init help; do + workspace launcher status usage doctor logs upgrade version env use completions shell-init help; do help_cmd help "$topic" assert_status 0 assert_contains "Usage: codex-profile $topic" diff --git a/test/cli/interactive-test.sh b/test/cli/interactive-test.sh index b9b9762..ad3d64c 100644 --- a/test/cli/interactive-test.sh +++ b/test/cli/interactive-test.sh @@ -125,7 +125,7 @@ test_picker_restores_terminal() { test_picker_cancellation_status_reaches_all_commands() { local command input expected INTERACTIVE_WAIT_FOR='Filter:' local -a args - for command in cli app run 'run --app'; do + for command in cli app app-instance 'app --instance' run 'run --app'; do read -r -a args <<< "$command" for input in '\003' '\033' '\004' 'q\n'; do prepare_interactive_test diff --git a/test/cli/launcher-test.sh b/test/cli/launcher-test.sh index f0751ec..f849899 100644 --- a/test/cli/launcher-test.sh +++ b/test/cli/launcher-test.sh @@ -190,6 +190,66 @@ test_launcher_cleans_up_a_failed_icon_build() { rm -rf "$tmp" } +test_launcher_reports_any_failed_build_step() { + local tmp copy + tmp="$(mktemp -d)" + prepare_launcher_test "$tmp" personal + # A copy that is not executable cannot be recorded as the launcher's target. + copy="$tmp/codex-profile" + cp "$SCRIPT" "$copy" + chmod 644 "$copy" + + run_cmd launcher_env "$tmp" bash "$copy" launcher create personal + + assert_status 1 + assert_contains "is not runnable" + assert_contains "Cannot build launcher for profile 'personal'" + assert_not_contains "Created launcher" + [[ ! -e "$tmp/apps/ChatGPT personal.app" ]] || fail "failed build installed a launcher" + [[ ! -e "$tmp/config/launchers/personal.state" ]] || fail "failed build recorded launcher state" + + rm -rf "$tmp" +} + +test_launcher_default_name_fits_any_profile_name() { + local tmp + tmp="$(mktemp -d)" + prepare_launcher_test "$tmp" work- + + run_cmd launcher_env "$tmp" "$SCRIPT" launcher create work- + + assert_status 0 + assert_contains "Created launcher for work-: $tmp/apps/ChatGPT work.app" + grep -Fq "app 'work-' \"\$HOME\"" "$tmp/apps/ChatGPT work.app/Contents/MacOS/launch-profile" || + fail "default-named launcher does not route to the selected profile" + + rm -rf "$tmp" +} + +test_launcher_force_can_change_only_letter_case() { + local tmp + tmp="$(mktemp -d)" + prepare_launcher_test "$tmp" work + mkdir -p "$tmp/case-probe" + touch "$tmp/case-probe/A" + if [[ ! -e "$tmp/case-probe/a" ]]; then + rm -rf "$tmp" + return 0 + fi + + run_cmd launcher_env "$tmp" "$SCRIPT" launcher create work --name "ChatGPT Work" + assert_status 0 + run_cmd launcher_env "$tmp" "$SCRIPT" launcher create work --name "ChatGPT work" --force + assert_status 0 + assert_contains "Created launcher for work: $tmp/apps/ChatGPT work.app" + [[ "$(ls "$tmp/apps")" == "ChatGPT work.app" ]] || fail "case-only rename left: $(ls -a "$tmp/apps")" + run_cmd launcher_env "$tmp" "$SCRIPT" launcher path work + assert_status 0 + assert_equals "$tmp/apps/ChatGPT work.app" + + rm -rf "$tmp" +} + test_launcher_remove_preserves_profile_data() { local tmp profile_home tmp="$(mktemp -d)" @@ -287,6 +347,9 @@ test_launcher_create_list_and_path_are_deterministic test_launcher_create_is_idempotent_and_force_replaces_managed_bundle test_launcher_refuses_invalid_inputs_and_unmanaged_collisions test_launcher_cleans_up_a_failed_icon_build +test_launcher_reports_any_failed_build_step +test_launcher_default_name_fits_any_profile_name +test_launcher_force_can_change_only_letter_case test_launcher_remove_preserves_profile_data test_profile_remove_refuses_managed_launcher_orphans test_launcher_recovers_after_the_bundle_is_deleted_outside_the_cli diff --git a/test/cli/profiles-test.sh b/test/cli/profiles-test.sh index 29e7658..c90f3c2 100755 --- a/test/cli/profiles-test.sh +++ b/test/cli/profiles-test.sh @@ -438,6 +438,15 @@ test_remove_refuses_profiles_with_shared_configuration_dependents() { [[ -d "$source_home" && -L "$target_home/config.toml" ]] || \ fail "refused source removal changed linked profiles" + # A case-insensitive volume opens the same home under another spelling. + if [[ -d "$tmp/home/.codex-PERSONAL" ]]; then + run_cmd env HOME="$tmp/home" "$SCRIPT" remove PERSONAL --yes + assert_status 1 + assert_contains "Profile 'PERSONAL' is stored as 'personal'" + [[ -d "$source_home" && -e "$target_home/config.toml" ]] || \ + fail "a differently cased name removed a shared configuration source" + fi + run_cmd env HOME="$tmp/home" "$SCRIPT" remove personal-2 --yes assert_status 0 run_cmd env HOME="$tmp/home" "$SCRIPT" remove personal --yes @@ -492,13 +501,29 @@ test_clone_config_refuses_sensitive_looking_config() { source_home="$tmp/home/.codex-work" target_home="$tmp/home/.codex-personal" mkdir -p "$source_home" "$target_home" - printf 'openai_api_key = "secret"\n' > "$source_home/config.toml" - run_cmd env HOME="$tmp/home" "$SCRIPT" clone-config work personal + local sample + for sample in \ + 'openai_api_key = "secret"' \ + 'BRAVE_KEY = "abc"' \ + 'GITHUB_PAT = "abc"' \ + 'args = ["--key", "abc"]' \ + 'url = "postgres://user:hunter2@db.example/app"' \ + 'args = ["sk-live-abcdef"]' \ + 'SENTRY_DSN = "https://abc@sentry.example/1"'; do + printf '%s\n' "$sample" > "$source_home/config.toml" + + run_cmd env HOME="$tmp/home" "$SCRIPT" clone-config work personal + + assert_status 1 + assert_contains "Refusing to copy config.toml because it contains sensitive-looking text" + [[ ! -e "$target_home/config.toml" ]] || fail "clone-config copied sensitive-looking config: $sample" + done - assert_status 1 - assert_contains "Refusing to copy config.toml because it contains sensitive-looking keys" - [[ ! -e "$target_home/config.toml" ]] || fail "clone-config copied sensitive-looking config" + printf 'model = "gpt-5"\nkeybindings = "vim"\n' > "$source_home/config.toml" + run_cmd env HOME="$tmp/home" "$SCRIPT" clone-config work personal + assert_status 0 + assert_contains "Copied config.toml" rm -rf "$tmp" } diff --git a/test/cli/prompt-test.sh b/test/cli/prompt-test.sh index 4df4ef9..a729466 100644 --- a/test/cli/prompt-test.sh +++ b/test/cli/prompt-test.sh @@ -58,6 +58,39 @@ for shell in bash zsh; do [[ ! -e "$TMP_ROOT/home/injected" ]] || fail 'prompt executed invalid profile text' done +# Python venv and similar tools save PS1 with the indicator inside, prefix it, +# and later restore the saved copy; no stale indicator may survive. +cat > "$TMP_ROOT/restore.sh" <<'CHECK' +set -eu +PS1='base> ' +if [ "$TEST_SHELL" = bash ]; then + render() { eval "$PROMPT_COMMAND"; printf '%s\n' "$PS1"; } +else + render() { local hook; for hook in "${precmd_functions[@]}"; do "$hook"; done; printf '%s\n' "$PS1"; } +fi +eval "$(codex-profile shell-init "$TEST_SHELL" --prompt)" +codex-profile use work +render +saved="$PS1" +PS1="(venv) $PS1" +render +codex-profile use other +render +PS1="$saved" +render +unset CODEX_PROFILE_NAME CODEX_HOME +render +PS1='[codex:not a profile] base> ' +render +CHECK + +for shell in bash zsh; do + command -v "$shell" > /dev/null 2>&1 || continue + run_cmd env HOME="$TMP_ROOT/home" PATH="$TMP_ROOT/bin:$PATH" TEST_SHELL="$shell" "$shell" -f "$TMP_ROOT/restore.sh" + assert_status 0 + assert_equals $'[codex:work] base> \n[codex:work] (venv) base> \n[codex:other] (venv) base> \n[codex:other] base> \nbase> \n[codex:not a profile] base> ' +done + # Bash users may already have multiple prompt hooks; preserve their array. # shellcheck disable=SC2016 # expanded by the child shell run_cmd env HOME="$TMP_ROOT/home" PATH="$TMP_ROOT/bin:$PATH" bash -eu -c ' diff --git a/test/cli/shell-test.sh b/test/cli/shell-test.sh index 5dc57dc..e958a5e 100755 --- a/test/cli/shell-test.sh +++ b/test/cli/shell-test.sh @@ -238,6 +238,17 @@ test_env_warns_to_stderr_without_polluting_stdout_when_uninitialized() { [[ "$out" != *"not initialized"* ]] || fail "env stdout must stay eval-safe (warning must not land on stdout)" [[ "$err" == *"profile 'ghost' is not initialized"* ]] || fail "env should warn on stderr for an uninitialized profile" + # A symlinked home would select another profile's auth and sessions. + mkdir -p "$tmp/home/.codex" + ln -s "$tmp/home/.codex" "$tmp/home/.codex-alias" + set +e + out="$(env HOME="$tmp/home" "$SCRIPT" env alias 2> "$tmp/err")" + status=$? + set -e + [[ "$status" -eq 1 ]] || fail "env selected a symlinked profile home" + [[ -z "$out" ]] || fail "env emitted exports for a symlinked profile home: $out" + grep -Fq "Refusing symlinked profile home" "$tmp/err" || fail "env did not explain the symlinked home" + rm -rf "$tmp" } diff --git a/test/cli/status-json-test.sh b/test/cli/status-json-test.sh index b11366e..1354ee1 100755 --- a/test/cli/status-json-test.sh +++ b/test/cli/status-json-test.sh @@ -115,6 +115,69 @@ FAKE_CODEX rm -rf "$tmp" } +test_status_json_stays_valid_when_a_profile_fails() { + local tmp fake_codex + tmp="$(mktemp -d)" + fake_codex="$tmp/codex" + cat > "$fake_codex" <<'FAKE_CODEX' +#!/usr/bin/env bash +case "$CODEX_HOME" in + *.codex-broken) printf 'database exploded\n' >&2; exit 7 ;; +esac +printf 'Logged in\n' +FAKE_CODEX + chmod 755 "$fake_codex" + mkdir -p "$tmp/home/.codex" "$tmp/home/.codex-broken" "$tmp/home/.codex-personal" + + set +e + output="$(env HOME="$tmp/home" CODEX_CLI="$fake_codex" "$SCRIPT" status --json 2> /dev/null)" + status=$? + set -e + assert_status 7 + # shellcheck disable=SC2016 # node reads the template literal, not the shell + JSON_PAYLOAD="$output" node -e ' + const payload = JSON.parse(process.env.JSON_PAYLOAD); + const states = payload.profiles.map((profile) => `${profile.name}:${profile.state}`).join(","); + if (states !== "default:ok,broken:error,personal:ok") throw new Error(states); + ' + + set +e + output="$(env HOME="$tmp/home" CODEX_CLI=/no/such/codex "$SCRIPT" status --json 2> /dev/null)" + status=$? + set -e + assert_status 1 + JSON_PAYLOAD="$output" node -e 'JSON.parse(process.env.JSON_PAYLOAD)' + + rm -rf "$tmp" +} + +test_status_rejects_invalid_profile_names() { + local tmp + tmp="$(mktemp -d)" + + run_cmd env HOME="$tmp/home" "$SCRIPT" status --json ../evil + assert_status 1 + assert_contains "Invalid profile '../evil'" + assert_not_contains '"profiles"' + + rm -rf "$tmp" +} + +test_status_stops_once_when_cli_missing() { + local tmp + tmp="$(mktemp -d)" + mkdir -p "$tmp/home/.codex" "$tmp/home/.codex-personal" + + run_cmd env HOME="$tmp/home" CODEX_CLI=/no/such/codex "$SCRIPT" status + + assert_status 1 + assert_contains "CODEX_CLI is set but not executable" + assert_not_contains "No such file or directory" + [[ "$(grep -c 'Error:' <<< "$output")" == 1 ]] || fail "missing CLI was reported more than once: $output" + + rm -rf "$tmp" +} + test_doctor_skips_status_when_cli_missing() { local tmp tmp="$(mktemp -d)" @@ -331,6 +394,9 @@ test_status_all_reports_missing_default_without_creating_it test_status_reports_arbitrary_discovered_profiles_and_skips_invalid_dirs test_status_treats_not_logged_in_as_normal_status test_status_propagates_unexpected_cli_failure +test_status_json_stays_valid_when_a_profile_fails +test_status_rejects_invalid_profile_names +test_status_stops_once_when_cli_missing test_doctor_skips_status_when_cli_missing test_doctor_check_reports_healthy_state test_status_json_reports_profiles_without_creating_missing_default diff --git a/test/cli/upgrade-test.sh b/test/cli/upgrade-test.sh index 611457d..e06ce53 100755 --- a/test/cli/upgrade-test.sh +++ b/test/cli/upgrade-test.sh @@ -88,6 +88,37 @@ test_upgrade_installs_commit_sha_ref_on_fresh_cache() { rm -rf "$tmp" } +test_upgrade_refuses_path_refs_and_anchors_relative_prefixes() { + local tmp repo + tmp="$(mktemp -d)" + tmp="$(cd "$tmp" && pwd -P)" + repo="$tmp/repo" + mkdir -p "$repo" + init_git_main_branch "$repo" + write_fake_upgrade_repo "$repo" "9.9.8" + git -C "$repo" add . + git -C "$repo" -c user.name=test -c user.email=test@example.com commit -m "v1" >/dev/null + + # bin is a directory in the checkout, not a ref. + run_cmd env HOME="$tmp/home" CODEX_PROFILE_UPGRADE_REPO="$repo" CODEX_PROFILE_UPGRADE_CACHE="$tmp/cache-path" \ + "$SCRIPT" upgrade --prefix "$tmp/prefix" --ref bin + assert_status 1 + assert_contains "Upgrade ref does not exist: bin" + [[ ! -e "$tmp/prefix/bin/codex-profile" ]] || fail "upgrade installed a path-shaped ref" + + set +e + output="$(cd "$tmp" && env HOME="$tmp/home" CODEX_PROFILE_UPGRADE_REPO="$repo" \ + CODEX_PROFILE_UPGRADE_CACHE="$tmp/cache-relative" "$SCRIPT" upgrade --prefix relative --ref main 2>&1)" + status=$? + set -e + assert_status 0 + assert_contains "Installed codex-profile 9.9.8 to $tmp/relative/bin/codex-profile" + [[ -x "$tmp/relative/bin/codex-profile" ]] || fail "relative prefix was not resolved from the caller's directory" + [[ -z "$(git -C "$tmp/cache-relative" status --porcelain)" ]] || fail "relative prefix installed into the upgrade cache" + + rm -rf "$tmp" +} + test_upgrade_refuses_dirty_cached_checkout() { local tmp repo cache prefix tmp="$(mktemp -d)" @@ -437,6 +468,7 @@ test_update_check_refreshes_cache_from_source() { test_upgrade_dry_run_reports_plan_without_mutating_files test_upgrade_fetches_newest_ref_and_installs_to_prefix test_upgrade_installs_commit_sha_ref_on_fresh_cache +test_upgrade_refuses_path_refs_and_anchors_relative_prefixes test_upgrade_refuses_dirty_cached_checkout test_upgrade_refuses_to_install_older_version test_upgrade_refuses_unversioned_candidate diff --git a/test/cli/workspace-test.sh b/test/cli/workspace-test.sh index 1f9fd5c..cbc1ffc 100755 --- a/test/cli/workspace-test.sh +++ b/test/cli/workspace-test.sh @@ -303,6 +303,32 @@ FAKE_MV assert_contains "Workspace guard mode: strict" [[ ! -e "$config/mutation.lock" ]] || fail "stale state mutation lock was not reclaimed" + # A waiter that saw the dead owner must not delete the lock another waiter + # reclaimed in the meantime. The ps stand-in plays that other waiter. + mkdir "$config/mutation.lock" + printf '99999999\n' > "$config/mutation.lock/pid" + cat > "$fake_bin/ps" <<'FAKE_PS' +#!/usr/bin/env bash +printf '%s\n' "${LIVE_OWNER:?}" > "${LOCK_DIR:?}/pid" +exit 1 +FAKE_PS + chmod 755 "$fake_bin/ps" + run_cmd env HOME="$tmp/home" CODEX_PROFILE_CONFIG_HOME="$config" CODEX_PROFILE_LOCK_ATTEMPTS=1 \ + PATH="$fake_bin:$PATH" REAL_MV="$real_mv" LIVE_OWNER="$$" LOCK_DIR="$config/mutation.lock" \ + "$SCRIPT" workspace guard warn + assert_status 1 + assert_contains "held by process $$" + [[ "$(cat "$config/mutation.lock/pid")" == "$$" ]] || fail "a stale-lock waiter removed a live owner's lock" + [[ "$(cat "$config/guard-mode")" == "strict" ]] || fail "a waiter changed state without the lock" + + # A lock without an owner names its own recovery. + rm -f "$config/mutation.lock/pid" + run_cmd env HOME="$tmp/home" CODEX_PROFILE_CONFIG_HOME="$config" CODEX_PROFILE_LOCK_ATTEMPTS=1 \ + "$SCRIPT" workspace guard warn + assert_status 1 + assert_contains "remove that directory and try again" + rmdir "$config/mutation.lock" + rm -rf "$tmp" } @@ -703,6 +729,53 @@ test_doctor_reports_workspace_binding_health_in_human_and_json_output() { assert_contains '"binding_count":0' JSON_PAYLOAD="$output" node -e 'JSON.parse(process.env.JSON_PAYLOAD)' + # Unreadable state is reported rather than ending the diagnosis. Root + # reads through file modes, so the check only means something otherwise. + if [[ "$(id -u)" != 0 ]]; then + printf 'strict\n' > "$config/guard-mode" + printf '%s\t%s\n' "$existing_one" work > "$config/workspaces.tsv" + chmod 000 "$config/guard-mode" "$config/workspaces.tsv" + run_cmd env HOME="$tmp/home" CODEX_PROFILE_CONFIG_HOME="$config" \ + CODEX_CLI=/no/such/codex "$SCRIPT" doctor --json + chmod 600 "$config/guard-mode" "$config/workspaces.tsv" + + assert_status 0 + assert_contains '"registry_valid":false' + JSON_PAYLOAD="$output" node -e 'JSON.parse(process.env.JSON_PAYLOAD)' + fi + + rm -rf "$tmp" +} + +test_workspace_paths_resolve_the_way_bind_records_them() { + local tmp config + tmp="$(mktemp -d)" + tmp="$(cd "$tmp" && pwd -P)" + config="$tmp/config" + mkdir -p "$tmp/home/.codex-work" "$tmp/elsewhere/project" "$tmp/cwd/project" "$tmp/real/linked-project" + ln -s "$tmp/real" "$tmp/linked" + + # An exported CDPATH must neither redirect nor decorate a relative path. + set +e + output="$(cd "$tmp/cwd" && env HOME="$tmp/home" CODEX_PROFILE_CONFIG_HOME="$config" \ + CDPATH="$tmp/elsewhere:." "$SCRIPT" workspace bind project work 2>&1)" + status=$? + set -e + assert_status 0 + assert_contains "Bound $tmp/cwd/project to profile work" + + # A folder bound through a symlinked parent can be unbound by the same + # spelling after the folder itself is gone. + run_cmd env HOME="$tmp/home" CODEX_PROFILE_CONFIG_HOME="$config" \ + "$SCRIPT" workspace bind "$tmp/linked/linked-project" work + assert_status 0 + assert_contains "Bound $tmp/real/linked-project to profile work" + rmdir "$tmp/real/linked-project" + run_cmd env HOME="$tmp/home" CODEX_PROFILE_CONFIG_HOME="$config" \ + "$SCRIPT" workspace unbind "$tmp/linked/linked-project/" + assert_status 0 + assert_contains "Unbound $tmp/real/linked-project" + rm -rf "$tmp" } @@ -716,3 +789,4 @@ test_workspace_resolution_errors_are_not_treated_as_unbound test_workspace_guards_explicit_profile_commands test_remove_cleans_workspace_bindings_without_touching_projects test_doctor_reports_workspace_binding_health_in_human_and_json_output +test_workspace_paths_resolve_the_way_bind_records_them From cd68ea10919c8a010b978baac9f8c33c58f9caf3 Mon Sep 17 00:00:00 2001 From: Chai Pin Zheng Date: Sat, 17 Oct 2026 17:52:26 +0800 Subject: [PATCH 2/5] frontend(fix): quote Terminal launches for fish and settle alert baselines - The menu app and Raycast build the Terminal command with quoting that sh, bash, zsh and fish read alike, and pass it as one osascript argument; quoted form of let a folder name escape quotes under fish. - Low-quota alerts leave still-refreshing readings out of observation, so another profile's reading cannot make a stale baseline. - Native tests tag their defaults suites per run and the test script removes those plists from ~/Library/Preferences. - Raycast reports usage timeouts as timeouts, resets a filter whose profile lost its bindings, and copies commands with the resolved CLI. Co-Authored-By: Claude Opus 5.5 --- .../Previews/RenderPreview.swift | 2 +- .../Sources/CodexProfilesMenu/CLIClient.swift | 56 ++++++---- .../CodexProfilesMenu/QuotaAlerts.swift | 5 +- .../CodexProfilesMenu/WorkspaceStore.swift | 12 ++- .../MenuInteractionTests.swift | 2 +- .../QuotaAlertTests.swift | 44 +++++++- .../CodexProfilesMenuTests/TestMain.swift | 77 +++++++------ .../CodexProfilesMenuTests/UsageTests.swift | 2 +- raycast/README.md | 6 +- raycast/src/cli.ts | 3 +- raycast/src/codex-usage.tsx | 2 +- raycast/src/lib/terminal.ts | 68 +++++------- raycast/src/open-profile.tsx | 3 +- raycast/src/open-workspace.tsx | 10 +- raycast/test/terminal.test.ts | 101 ++++++++++++------ test/macos/menu-app-test.sh | 11 +- 16 files changed, 261 insertions(+), 143 deletions(-) diff --git a/macos/CodexProfilesMenu/Previews/RenderPreview.swift b/macos/CodexProfilesMenu/Previews/RenderPreview.swift index 5cc66c9..bc3ddf4 100644 --- a/macos/CodexProfilesMenu/Previews/RenderPreview.swift +++ b/macos/CodexProfilesMenu/Previews/RenderPreview.swift @@ -14,7 +14,7 @@ struct RenderPreview { application.setActivationPolicy(.prohibited) application.appearance = NSAppearance(named: CommandLine.arguments.contains("--dark") ? .darkAqua : .aqua) - let suite = "menu-preview-\(UUID().uuidString)" + let suite = WorkspaceStore.testSuiteName("menu-preview") let defaults = UserDefaults(suiteName: suite)! defer { defaults.removePersistentDomain(forName: suite) } let store = WorkspaceStore(client: CLIClient(executableURL: nil), defaults: defaults) diff --git a/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/CLIClient.swift b/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/CLIClient.swift index 048fd10..1217813 100644 --- a/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/CLIClient.swift +++ b/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/CLIClient.swift @@ -166,7 +166,11 @@ struct CLIClient: Sendable { try await Task.detached(priority: .userInitiated) { let result = try await ProcessRunner().run( executableURL: URL(fileURLWithPath: "/usr/bin/osascript"), - arguments: ["-e", Self.terminalLoginAppleScript, executableURL.path, profile] + arguments: [ + "-e", + Self.terminalAppleScript, + Self.terminalLoginCommand(toolPath: executableURL.path, profile: profile), + ] ) guard result.terminationStatus == 0 else { throw CLIClientError.commandFailed(Self.terminalErrorMessage(from: result)) @@ -193,9 +197,12 @@ struct CLIClient: Sendable { arguments: [ "-e", Self.terminalAppleScript, - executableURL.path, - target.profile, - target.workspace?.path ?? FileManager.default.homeDirectoryForCurrentUser.path, + Self.terminalLaunchCommand( + toolPath: executableURL.path, + profile: target.profile, + workspacePath: target.workspace?.path + ?? FileManager.default.homeDirectoryForCurrentUser.path + ), ] ) guard result.terminationStatus == 0 else { @@ -210,30 +217,39 @@ struct CLIClient: Sendable { return executableURL } + /// The finished shell command travels as an osascript argument, so no + /// runtime value is interpolated into AppleScript source. static let terminalAppleScript = """ on run argv - set toolPath to item 1 of argv - set profileName to item 2 of argv - set workspacePath to item 3 of argv - set launchCommand to "cd " & quoted form of workspacePath & " && exec " & quoted form of toolPath & " cli " & quoted form of profileName tell application "Terminal" activate - do script launchCommand + do script (item 1 of argv) end tell end run """ - static let terminalLoginAppleScript = """ - on run argv - set toolPath to item 1 of argv - set profileName to item 2 of argv - set loginCommand to "exec " & quoted form of toolPath & " login " & quoted form of profileName - tell application "Terminal" - activate - do script loginCommand - end tell - end run - """ + static func terminalLaunchCommand(toolPath: String, profile: String, workspacePath: String) -> String { + "cd \(shellQuote(workspacePath)) && exec \(shellQuote(toolPath)) cli \(shellQuote(profile))" + } + + static func terminalLoginCommand(toolPath: String, profile: String) -> String { + "exec \(shellQuote(toolPath)) login \(shellQuote(profile))" + } + + /// Terminal runs the command in the user's login shell. AppleScript's + /// `quoted form of` is POSIX-only: fish reads `\'` and `\\` inside single + /// quotes as escapes, so quotes and backslashes are written outside them. + static func shellQuote(_ value: String) -> String { + var quoted = "'" + for character in value { + switch character { + case "'": quoted += "'\\''" + case "\\": quoted += "'\\\\'" + default: quoted.append(character) + } + } + return quoted + "'" + } static func isValidProfileName(_ profile: String) -> Bool { profile.range(of: "^[A-Za-z0-9][A-Za-z0-9._-]*$", options: .regularExpression) diff --git a/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/QuotaAlerts.swift b/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/QuotaAlerts.swift index 555a185..7a6ce3a 100644 --- a/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/QuotaAlerts.swift +++ b/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/QuotaAlerts.swift @@ -364,7 +364,10 @@ final class QuotaAlertController { func usageDidChange(at now: Date = Date()) { updateIndicator(at: now) guard isEnabled else { return } - for alert in tracker.observe(store.usage, profiles: store.profiles, at: now) { + // Another profile's fresh reading must not turn a reading that is + // still refreshing into a baseline; it keeps its earlier one instead. + let settled = store.usage.filter { !store.readingUsage.contains($0.key) } + for alert in tracker.observe(settled, profiles: store.profiles, at: now) { let headroom = QuotaHeadroom.suggestion(for: alert, usage: store.usage, profiles: store.profiles, at: now) // Posting without permission fails quietly; the icon still shows it. notifier.post(QuotaNotification(alert: alert, headroom: headroom, at: now)) diff --git a/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/WorkspaceStore.swift b/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/WorkspaceStore.swift index b4bedda..f609880 100644 --- a/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/WorkspaceStore.swift +++ b/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/WorkspaceStore.swift @@ -449,12 +449,22 @@ final class WorkspaceStore { } #if TESTING + /// A unique defaults suite for a test or preview. Removing its domain + /// still leaves an empty plist in the real ~/Library/Preferences (HOME + /// does not move it), so the suite carries the run's + /// PROFILE_TEST_DEFAULTS_RUN tag for the test script to sweep afterwards. + nonisolated static func testSuiteName(_ name: String) -> String { + let run = ProcessInfo.processInfo.environment["PROFILE_TEST_DEFAULTS_RUN"].map { ".\($0)" } ?? "" + return "\(name)\(run).\(UUID().uuidString)" + } + func loadPreview(_ bindings: [WorkspaceBinding], profiles previewProfiles: [String]? = nil, usage previewUsage: [String: ProfileUsage] = [:], readingUsage previewReading: Set = [], changingPaths previewChanging: Set = []) { workspaces = bindings profiles = Array(Set(previewProfiles ?? bindings.filter(\.profileExists).map(\.profile))).sorted() - usage = previewUsage + // Like refreshUsage, settle readings before usage observers run. readingUsage = previewReading + usage = previewUsage changingPaths = previewChanging guardMode = "warn" hasLoaded = true diff --git a/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/MenuInteractionTests.swift b/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/MenuInteractionTests.swift index 5ddff35..3d80a62 100644 --- a/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/MenuInteractionTests.swift +++ b/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/MenuInteractionTests.swift @@ -7,7 +7,7 @@ struct MenuInteractionTests { static func main() async throws { let app = NSApplication.shared app.setActivationPolicy(.prohibited) - let suite = "menu-interaction-\(UUID().uuidString)" + let suite = WorkspaceStore.testSuiteName("menu-interaction") let defaults = UserDefaults(suiteName: suite)! defer { defaults.removePersistentDomain(forName: suite) } let root = FileManager.default.temporaryDirectory.appendingPathComponent(suite) diff --git a/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/QuotaAlertTests.swift b/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/QuotaAlertTests.swift index 97604f8..5b97a8e 100644 --- a/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/QuotaAlertTests.swift +++ b/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/QuotaAlertTests.swift @@ -11,6 +11,7 @@ enum QuotaAlertTests { print("Checking low-quota alert permission and notifications…") try await controllerAsksOnlyWhenTurnedOn() await enablingIgnoresUnsettledReadings() + await otherReadingsKeepUnsettledProfilesOutOfTheBaseline() await unansweredPermissionStopsWaiting() print("Checking background usage refresh only while alerts are on…") try await backgroundRefreshFollowsAlerts() @@ -219,7 +220,7 @@ enum QuotaAlertTests { } private static func preferencePersistence() { - let suite = "QuotaAlertPreferences.\(UUID().uuidString)" + let suite = WorkspaceStore.testSuiteName("QuotaAlertPreferences") let defaults = UserDefaults(suiteName: suite)! defer { defaults.removePersistentDomain(forName: suite) } expect(!QuotaAlertPreferences(defaults: defaults).isEnabled, "low-quota alerts must default to off") @@ -232,7 +233,7 @@ enum QuotaAlertTests { } private static func controllerAsksOnlyWhenTurnedOn() async throws { - let suite = "QuotaAlertController.\(UUID().uuidString)" + let suite = WorkspaceStore.testSuiteName("QuotaAlertController") let defaults = UserDefaults(suiteName: suite)! defer { defaults.removePersistentDomain(forName: suite) } // Fresh readings stay cached, so the background pass started by turning @@ -301,7 +302,7 @@ enum QuotaAlertTests { } private static func enablingIgnoresUnsettledReadings() async { - let suite = "QuotaAlertSettled.\(UUID().uuidString)" + let suite = WorkspaceStore.testSuiteName("QuotaAlertSettled") let defaults = UserDefaults(suiteName: suite)! defer { defaults.removePersistentDomain(forName: suite) } let store = WorkspaceStore(client: CLIClient(executableURL: nil), defaults: defaults) @@ -325,8 +326,41 @@ enum QuotaAlertTests { _ = await alerts.setEnabled(false) } + private static func otherReadingsKeepUnsettledProfilesOutOfTheBaseline() async { + let suite = WorkspaceStore.testSuiteName("QuotaAlertOtherSettled") + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let store = WorkspaceStore(client: CLIClient(executableURL: nil), defaults: defaults) + let opened = Date() + let stale = opened.addingTimeInterval(-7200) + func weekly(_ remaining: Double, checkedAt: Date) -> ProfileUsage { + reading([window(remaining, 10080, resetIn: 86400, from: opened)], checkedAt: checkedAt) + } + // Both profiles show two-hour-old readings while their refreshes run. + store.loadPreview([], profiles: ["personal", "work"], usage: [ + "personal": weekly(40, checkedAt: stale), + "work": weekly(30, checkedAt: stale), + ], readingUsage: ["personal", "work"]) + let notifier = FakeNotifier() + notifier.current = .allowed + let alerts = QuotaAlertController(store: store, notifier: notifier, defaults: defaults, refreshInterval: 3600) + expect(await alerts.setEnabled(true) == .enabled, "alerts could not be turned on") + // personal settles first; work's stale reading is still on screen. + store.loadPreview([], profiles: ["personal", "work"], usage: [ + "personal": weekly(40, checkedAt: Date()), + "work": weekly(30, checkedAt: stale), + ], readingUsage: ["work"]) + store.loadPreview([], profiles: ["personal", "work"], usage: [ + "personal": weekly(40, checkedAt: Date()), + "work": weekly(20, checkedAt: Date()), + ]) + expect(notifier.posted.isEmpty, + "another profile's reading must not make a still-refreshing reading the baseline for a crossing") + _ = await alerts.setEnabled(false) + } + private static func unansweredPermissionStopsWaiting() async { - let suite = "QuotaAlertTimeout.\(UUID().uuidString)" + let suite = WorkspaceStore.testSuiteName("QuotaAlertTimeout") let defaults = UserDefaults(suiteName: suite)! defer { defaults.removePersistentDomain(forName: suite) } let store = WorkspaceStore(client: CLIClient(executableURL: nil), defaults: defaults) @@ -347,7 +381,7 @@ enum QuotaAlertTests { } private static func backgroundRefreshFollowsAlerts() async throws { - let suite = "QuotaAlertRefresh.\(UUID().uuidString)" + let suite = WorkspaceStore.testSuiteName("QuotaAlertRefresh") let defaults = UserDefaults(suiteName: suite)! defer { defaults.removePersistentDomain(forName: suite) } let fixture = try UsageTests.Fixture() diff --git a/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/TestMain.swift b/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/TestMain.swift index 4c13790..385de4b 100644 --- a/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/TestMain.swift +++ b/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/TestMain.swift @@ -89,10 +89,10 @@ struct CodexProfilesMenuTests { private static func testTerminalScriptQuotesRuntimeArgumentsInsteadOfInterpolatingThem() { let script = CLIClient.terminalAppleScript + let command = CLIClient.terminalLaunchCommand(toolPath: "/Users/test/bin/codex-profile", profile: "work", workspacePath: "/Users/test/My App") - expect(script.contains("quoted form of workspacePath"), "workspace path is not shell quoted") - expect(script.contains("quoted form of toolPath"), "tool path is not shell quoted") - expect(script.contains("quoted form of profileName"), "profile is not shell quoted") + expect(script.contains("do script (item 1 of argv)"), "Terminal does not run the argument command") + expect(command == "cd '/Users/test/My App' && exec '/Users/test/bin/codex-profile' cli 'work'", "launch command is not shell quoted") expect(!script.contains("/Users/test"), "runtime path was interpolated into AppleScript") } @@ -142,7 +142,7 @@ struct CodexProfilesMenuTests { throw CLIClientError.commandFailed("Tests require an isolated HOME and PROFILE_TEST_CLI.") } let client = CLIClient(executableURL: URL(fileURLWithPath: cliPath)) - let suite = "CodexProfilesMenuTests.realCLI." + UUID().uuidString + let suite = WorkspaceStore.testSuiteName("CodexProfilesMenuTests.realCLI") let defaults = UserDefaults(suiteName: suite)! defer { defaults.removePersistentDomain(forName: suite) } let store = WorkspaceStore(client: client, defaults: defaults) @@ -219,37 +219,54 @@ struct CodexProfilesMenuTests { private static func testTerminalCommandsPreserveArguments() async throws { let probe = try executable("tool ' \"$HOME\" `echo bad` ;", body: "printf '%s\\n' \"$PWD\" \"$@\"\n") - let folder = probe.deletingLastPathComponent().appendingPathComponent("folder ' \"$HOME\" $(echo bad) ;") - try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) - for (script, command, arguments) in [ - (CLIClient.terminalAppleScript, "launchCommand", [probe.path, "work-main", folder.path]), - (CLIClient.terminalLoginAppleScript, "loginCommand", [probe.path, "default"]), - ] { - let commandScript = script.replacingOccurrences( - of: "tell application \"Terminal\"\n activate\n do script \(command)\n end tell", - with: "return \(command)" + let parent = probe.deletingLastPathComponent() + // fish reads \' and \\ inside single quotes as escapes; neither may end + // the quoted path or run the rest of it as a command. + let folders = [ + "folder ' \"$HOME\" $(echo bad) ;", + "fish \\'; touch INJECTED; #", + "double \\\\ slash", + "trailing \\", + ].map { parent.appendingPathComponent($0) } + for folder in folders { + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + } + let shells = ["/bin/sh", "/bin/zsh", "/bin/bash", "/opt/homebrew/bin/fish", "/usr/local/bin/fish"] + .filter { FileManager.default.isExecutableFile(atPath: $0) } + var commands = folders.map { + (CLIClient.terminalLaunchCommand(toolPath: probe.path, profile: "work-main", workspacePath: $0.path), Optional($0)) + } + commands.append((CLIClient.terminalLoginCommand(toolPath: probe.path, profile: "default"), nil)) + + for (shellCommand, folder) in commands { + let commandScript = CLIClient.terminalAppleScript.replacingOccurrences( + of: "tell application \"Terminal\"\n activate\n do script (item 1 of argv)\n end tell", + with: "return item 1 of argv" ) expect(!commandScript.contains("tell application"), "test would invoke Terminal") - let generated = try await ProcessRunner().run( + let passed = try await ProcessRunner().run( executableURL: URL(fileURLWithPath: "/usr/bin/osascript"), - arguments: ["-e", commandScript] + arguments - ) - expect(generated.terminationStatus == 0, "AppleScript did not parse") - guard let shellCommand = String(data: generated.standardOutput, encoding: .utf8) else { - throw CLIClientError.invalidResponse("AppleScript did not return a command") - } - let executed = try await ProcessRunner().run( - executableURL: URL(fileURLWithPath: "/bin/sh"), - arguments: ["-c", shellCommand] + arguments: ["-e", commandScript, shellCommand] ) - expect(executed.terminationStatus == 0, "quoted command could not execute") - let output = String(data: executed.standardOutput, encoding: .utf8) ?? "" - if command == "launchCommand" { - expect(output == "\(folder.path)\ncli\nwork-main\n", "workspace or CLI arguments were shell interpreted") - } else { - expect(output.hasSuffix("\nlogin\ndefault\n"), "login command arguments changed") + expect(passed.terminationStatus == 0, "AppleScript did not parse") + expect(String(data: passed.standardOutput, encoding: .utf8) == shellCommand + "\n", "osascript changed the command") + + for shell in shells { + let executed = try await ProcessRunner().run( + executableURL: URL(fileURLWithPath: shell), + arguments: ["-c", shellCommand] + ) + expect(executed.terminationStatus == 0, "\(shell) could not execute the quoted command") + let output = String(data: executed.standardOutput, encoding: .utf8) ?? "" + if let folder { + expect(output == "\(folder.path)\ncli\nwork-main\n", "\(shell) interpreted the workspace or CLI arguments") + } else { + expect(output.hasSuffix("\nlogin\ndefault\n"), "\(shell) changed the login command arguments") + } } } + expect(!FileManager.default.fileExists(atPath: parent.appendingPathComponent("INJECTED").path), "a shell ran text from a workspace path") + expect(!FileManager.default.fileExists(atPath: FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("INJECTED").path), "a shell ran text from a workspace path") } private static func executable(_ name: String, body: String) throws -> URL { @@ -513,7 +530,7 @@ struct CodexProfilesMenuTests { init() throws { root = FileManager.default.temporaryDirectory.appendingPathComponent("CodexProfilesMenuTests-" + UUID().uuidString) - suite = "CodexProfilesMenuTests." + UUID().uuidString + suite = WorkspaceStore.testSuiteName("CodexProfilesMenuTests") defaults = UserDefaults(suiteName: suite)! try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) try write("default\npersonal\nwork\n", to: "profiles") diff --git a/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/UsageTests.swift b/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/UsageTests.swift index a9e9b0a..93eeeb9 100644 --- a/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/UsageTests.swift +++ b/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/UsageTests.swift @@ -151,7 +151,7 @@ enum UsageTests { let fixture = try Fixture() defer { fixture.cleanup() } try fixture.write("", to: "hold") - let suite = "UsageStoreTests.\(UUID().uuidString)" + let suite = WorkspaceStore.testSuiteName("UsageStoreTests") let defaults = UserDefaults(suiteName: suite)! defer { defaults.removePersistentDomain(forName: suite) } let store = WorkspaceStore(client: CLIClient(executableURL: fixture.executable), defaults: defaults) diff --git a/raycast/README.md b/raycast/README.md index 9316c6d..73ae4e9 100644 --- a/raycast/README.md +++ b/raycast/README.md @@ -96,9 +96,9 @@ launches) run `codex-profile` directly with an argument list, never through a shell, and without `CODEX_ACCESS_TOKEN`, `CODEX_API_KEY` or `OPENAI_API_KEY`. Terminal launches ask Terminal to run `codex-profile cli` or `codex-profile login` in -a new window, in your own shell and its environment; the command is built -with AppleScript's `quoted form of`, so profile names and folder paths are -never interpreted by the shell. When no install is found in the usual +a new window, in your own shell and its environment; profile names and +folder paths are single-quoted the way sh, bash, zsh and fish all read +literally, so they are never interpreted by the shell. When no install is found in the usual locations, discovery runs your login shell with the fixed command `command -v codex-profile`. diff --git a/raycast/src/cli.ts b/raycast/src/cli.ts index d69f054..a27542c 100644 --- a/raycast/src/cli.ts +++ b/raycast/src/cli.ts @@ -118,7 +118,8 @@ function run( stdout, stderr, code: typeof error.code === "number" ? error.code : null, - timedOut: error.killed === true && error.signal === "SIGTERM" && !options.signal?.aborted, + // A timeout kills the child; the CLI may trap the signal and exit with a status. + timedOut: error.killed === true && !options.signal?.aborted, }); }, ); diff --git a/raycast/src/codex-usage.tsx b/raycast/src/codex-usage.tsx index 7f75dd7..088c412 100644 --- a/raycast/src/codex-usage.tsx +++ b/raycast/src/codex-usage.tsx @@ -153,7 +153,7 @@ function ProfileRows(props: { cli: string; profile: ProfileUsage; now: number; r {refreshAction} diff --git a/raycast/src/lib/terminal.ts b/raycast/src/lib/terminal.ts index 4031777..d03762b 100644 --- a/raycast/src/lib/terminal.ts +++ b/raycast/src/lib/terminal.ts @@ -1,50 +1,36 @@ -// Terminal launches, copied from the menu-bar app (CLIClient.swift): values -// travel as osascript arguments and AppleScript's `quoted form of` builds the -// shell command, so nothing is interpolated into script source. Pure module. - -export type TerminalAction = "cli" | "login"; - -/** AppleScript lines that set `launchCommand` from `argv`. */ -const COMMAND_BUILDERS: Record = { - cli: [ - " set toolPath to item 1 of argv", - " set profileName to item 2 of argv", - " set workspacePath to item 3 of argv", - ' set launchCommand to "cd " & quoted form of workspacePath & " && exec " & quoted form of toolPath & " cli " & quoted form of profileName', - ].join("\n"), - login: [ - " set toolPath to item 1 of argv", - " set profileName to item 2 of argv", - ' set launchCommand to "exec " & quoted form of toolPath & " login " & quoted form of profileName', - ].join("\n"), -}; - -/** Opens a new Terminal window running the command. */ -export function terminalScript(action: TerminalAction): string { - return [ - "on run argv", - COMMAND_BUILDERS[action], - ' tell application "Terminal"', - " activate", - " do script launchCommand", - " end tell", - "end run", - ].join("\n"); +// Terminal launches, copied from the menu-bar app (CLIClient.swift): the shell +// command is quoted here and travels as one osascript argument, so nothing is +// interpolated into script source. AppleScript's `quoted form of` is not used +// because fish reads backslashes inside single quotes differently. Pure module. + +/** Opens a new Terminal window running the command in `argv`. */ +export const TERMINAL_SCRIPT = [ + "on run argv", + ' tell application "Terminal"', + " activate", + " do script (item 1 of argv)", + " end tell", + "end run", +].join("\n"); + +/** The shell command that starts `codex-profile cli ` in `workspace`. */ +export function cliCommand(toolPath: string, profile: string, workspace: string): string { + return `cd ${shellQuote(workspace)} && exec ${shellQuote(toolPath)} cli ${shellQuote(profile)}`; } -/** Returns the command Terminal would run, without opening Terminal. Used by tests. */ -export function terminalCommandScript(action: TerminalAction): string { - return ["on run argv", COMMAND_BUILDERS[action], " return launchCommand", "end run"].join("\n"); +/** The shell command that starts `codex-profile login `. */ +export function loginCommand(toolPath: string, profile: string): string { + return `exec ${shellQuote(toolPath)} login ${shellQuote(profile)}`; } /** osascript arguments for `codex-profile cli ` started in `workspace`. */ export function cliLaunchArguments(toolPath: string, profile: string, workspace: string): string[] { - return ["-e", terminalScript("cli"), toolPath, profile, workspace]; + return ["-e", TERMINAL_SCRIPT, cliCommand(toolPath, profile, workspace)]; } /** osascript arguments for `codex-profile login `. */ export function loginLaunchArguments(toolPath: string, profile: string): string[] { - return ["-e", terminalScript("login"), toolPath, profile]; + return ["-e", TERMINAL_SCRIPT, loginCommand(toolPath, profile)]; } /** osascript reports a denied Automation permission as error -1743. */ @@ -65,8 +51,12 @@ export function terminalErrorMessage(message: string, timedOut = false): string return isAutomationDenied(message) ? AUTOMATION_DENIED_MESSAGE : message.trim() || "Terminal could not be opened."; } -/** POSIX single quoting for a command the person copies into a shell. */ +/** + * Single quoting that sh, bash, zsh and fish all read the same way. Quotes and + * backslashes are written outside the quotes, because fish treats `\'` and + * `\\` inside single quotes as escapes. + */ export function shellQuote(value: string): string { if (/^[A-Za-z0-9_./:=@%+-]+$/.test(value)) return value; - return `'${value.replace(/'/g, `'\\''`)}'`; + return `'${value.replace(/['\\]/g, (character) => `'\\${character}'`)}'`; } diff --git a/raycast/src/open-profile.tsx b/raycast/src/open-profile.tsx index 3a93b25..878b310 100644 --- a/raycast/src/open-profile.tsx +++ b/raycast/src/open-profile.tsx @@ -3,6 +3,7 @@ import { useCachedPromise, useFrecencySorting } from "@raycast/utils"; import { useRef } from "react"; import { HOME, loadProfiles, type ProfileEntry } from "./cli"; import { displayPath } from "./lib/profile"; +import { shellQuote } from "./lib/terminal"; import { offersSignIn, usageHeadline, type ProfileUsage } from "./lib/usage"; import { CliGate, LaunchActions, useNow, useUsage } from "./shared"; @@ -60,7 +61,7 @@ function ProfileList(props: { cli: string }) { description="Create one in Terminal with codex-profile setup work, then come back." actions={ - + } diff --git a/raycast/src/open-workspace.tsx b/raycast/src/open-workspace.tsx index 0de039a..3a999e1 100644 --- a/raycast/src/open-workspace.tsx +++ b/raycast/src/open-workspace.tsx @@ -27,7 +27,9 @@ function WorkspaceList(props: { cli: string }) { key: (binding) => binding.path, }); const profiles = [...new Set(bindings.map((binding) => binding.profile))].sort((a, b) => a.localeCompare(b)); - const visible = sorted.filter((binding) => profileFilter === ALL_PROFILES || binding.profile === profileFilter); + // A refresh can remove the filtered profile's last binding and hide the dropdown. + const activeFilter = profiles.includes(profileFilter) ? profileFilter : ALL_PROFILES; + const visible = sorted.filter((binding) => activeFilter === ALL_PROFILES || binding.profile === activeFilter); return ( 1 ? ( - + {profiles.map((profile) => ( @@ -65,7 +67,7 @@ function WorkspaceList(props: { cli: string }) { description="Bind a project folder to a profile with codex-profile workspace bind ." actions={ - + } @@ -119,7 +121,7 @@ function WorkspaceItem(props: { cli: string; binding: WorkspaceBinding; onLaunch {problem ? ( ) : null} diff --git a/raycast/test/terminal.test.ts b/raycast/test/terminal.test.ts index cb55f07..101a289 100644 --- a/raycast/test/terminal.test.ts +++ b/raycast/test/terminal.test.ts @@ -1,34 +1,43 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { chmodSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { after, describe, test } from "node:test"; import { AUTOMATION_DENIED_MESSAGE, + cliCommand, cliLaunchArguments, isAutomationDenied, + loginCommand, loginLaunchArguments, shellQuote, + TERMINAL_SCRIPT, TERMINAL_TIMEOUT_MESSAGE, - terminalCommandScript, terminalErrorMessage, - terminalScript, } from "../src/lib/terminal.ts"; const onMac = process.platform === "darwin"; describe("Terminal scripts", () => { - test("pass every value as an osascript argument", () => { + test("pass the quoted command as one osascript argument", () => { const args = cliLaunchArguments("/opt/homebrew/bin/codex-profile", "work", "/Users/me/My App"); - assert.deepEqual(args.slice(2), ["/opt/homebrew/bin/codex-profile", "work", "/Users/me/My App"]); - assert.equal(args[0], "-e"); - assert.ok(!args[1].includes("My App") && !args[1].includes('work"'), "values never enter script source"); - assert.match(args[1], /tell application "Terminal"/); - assert.match(args[1], /do script launchCommand/); - assert.equal((terminalScript("cli").match(/quoted form of/g) ?? []).length, 3); - assert.deepEqual(loginLaunchArguments("/bin/codex-profile", "work").slice(2), ["/bin/codex-profile", "work"]); - assert.match(terminalScript("login"), / login " & quoted form of profileName/); + assert.deepEqual(args, [ + "-e", + TERMINAL_SCRIPT, + "cd '/Users/me/My App' && exec /opt/homebrew/bin/codex-profile cli work", + ]); + assert.ok( + !TERMINAL_SCRIPT.includes("My App") && !TERMINAL_SCRIPT.includes("work"), + "values never enter script source", + ); + assert.match(TERMINAL_SCRIPT, /tell application "Terminal"/); + assert.match(TERMINAL_SCRIPT, /do script \(item 1 of argv\)/); + assert.deepEqual(loginLaunchArguments("/bin/codex-profile", "work"), [ + "-e", + TERMINAL_SCRIPT, + "exec /bin/codex-profile login work", + ]); }); test("explain a denied Automation permission", () => { @@ -51,16 +60,27 @@ describe("Terminal scripts", () => { describe("shellQuote", () => { test("round-trips through /bin/sh", () => { - for (const value of ["work", "/Users/me/My App", "it's", "$(touch nope)", 'a"b', "", "semi;colon"]) { + for (const value of [ + "work", + "/Users/me/My App", + "it's", + "$(touch nope)", + 'a"b', + "", + "semi;colon", + "a\\b", + "end\\", + ]) { const output = execFileSync("/bin/sh", ["-c", `printf '%s' ${shellQuote(value)}`], { encoding: "utf8" }); assert.equal(output, value); } assert.equal(shellQuote("work"), "work"); assert.equal(shellQuote("it's"), "'it'\\''s'"); + assert.equal(shellQuote("a\\b"), "'a'\\\\'b'"); }); }); -describe("Terminal commands built by AppleScript", { skip: onMac ? false : "needs macOS osascript" }, () => { +describe("Terminal commands", () => { const root = mkdtempSync(join(tmpdir(), "codex-profile-raycast-")); after(() => rmSync(root, { recursive: true, force: true })); @@ -70,31 +90,46 @@ describe("Terminal commands built by AppleScript", { skip: onMac ? false : "need const tool = join(toolDirectory, "codex-profile"); writeFileSync(tool, '#!/bin/sh\nprintf "%s\\n" "$PWD" "$@"\n'); chmodSync(tool, 0o755); - const workspace = join(root, 'My "quoted" project\'s $HOME'); - mkdirSync(workspace); + const workspaces = [ + join(root, 'My "quoted" project\'s $HOME'), + // fish reads \' and \\ inside single quotes as escapes; neither may end the + // quoted path or run the rest of it as a command. + join(root, "a\\';touch PWNED;#"), + join(root, "x\\\\y"), + join(root, "trailing\\"), + ]; + for (const workspace of workspaces) mkdirSync(workspace); + const shells = ["/bin/sh", "/bin/zsh", "/bin/bash", "/opt/homebrew/bin/fish", "/usr/local/bin/fish"].filter((shell) => + existsSync(shell), + ); - const commandFor = (action: "cli" | "login", args: string[]) => - execFileSync("/usr/bin/osascript", ["-e", terminalCommandScript(action), ...args], { encoding: "utf8" }).trim(); - - test("start codex-profile cli in the workspace", () => { - const command = commandFor("cli", [tool, "work", workspace]); - for (const shell of ["/bin/sh", "/bin/zsh", "/bin/bash"]) { - const output = execFileSync(shell, ["-c", command], { encoding: "utf8", cwd: root }).split("\n"); - assert.ok([workspace, realpathSync(workspace)].includes(output[0]), `${shell} changed into the workspace`); - assert.deepEqual(output.slice(1, 3), ["cli", "work"], shell); + test("start codex-profile cli in the workspace in every shell", () => { + for (const workspace of workspaces) { + for (const shell of shells) { + const output = execFileSync(shell, ["-c", cliCommand(tool, "work", workspace)], { + encoding: "utf8", + cwd: root, + }).split("\n"); + assert.ok([workspace, realpathSync(workspace)].includes(output[0]), `${shell} changed into ${workspace}`); + assert.deepEqual(output.slice(1, 3), ["cli", "work"], shell); + } } + assert.equal(existsSync(join(root, "PWNED")), false, "no shell ran text from the workspace path"); }); test("start codex-profile login", () => { - const command = commandFor("login", [tool, "personal-2"]); - const output = execFileSync("/bin/sh", ["-c", command], { encoding: "utf8", cwd: root }).split("\n"); - assert.deepEqual(output.slice(1, 3), ["login", "personal-2"]); + for (const shell of shells) { + const output = execFileSync(shell, ["-c", loginCommand(tool, "personal-2")], { + encoding: "utf8", + cwd: root, + }).split("\n"); + assert.deepEqual(output.slice(1, 3), ["login", "personal-2"], shell); + } }); - test("match the script that drives Terminal", () => { - for (const action of ["cli", "login"] as const) { - const prelude = terminalCommandScript(action).split("\n return launchCommand")[0]; - assert.ok(terminalScript(action).startsWith(prelude), `${action} scripts share their command builder`); - } + test("compile the Terminal script", { skip: onMac ? false : "needs macOS osacompile" }, () => { + const compiled = join(root, "terminal.scpt"); + execFileSync("/usr/bin/osacompile", ["-o", compiled, "-e", TERMINAL_SCRIPT]); + assert.equal(existsSync(compiled), true); }); }); diff --git a/test/macos/menu-app-test.sh b/test/macos/menu-app-test.sh index 7612d4d..8eba1f0 100755 --- a/test/macos/menu-app-test.sh +++ b/test/macos/menu-app-test.sh @@ -27,7 +27,16 @@ if [[ "$(uname -s)" != "Darwin" ]] || ! command -v swiftc >/dev/null 2>&1; then fi TMP_DIR="$(mktemp -d)" -trap 'rm -rf "$TMP_DIR"' EXIT HUP INT TERM +# Tests and previews tag their defaults suites with this run. macOS keeps an +# empty plist for each in the real ~/Library/Preferences even after the domain +# is removed (HOME does not move it), so sweep exactly this run's files. +PROFILE_TEST_DEFAULTS_RUN="run$(uuidgen | tr -d '-')" +export PROFILE_TEST_DEFAULTS_RUN +cleanup() { + rm -rf "$TMP_DIR" + rm -f "$HOME/Library/Preferences/"*".$PROFILE_TEST_DEFAULTS_RUN."*.plist +} +trap cleanup EXIT HUP INT TERM SWIFT_TARGET="$(uname -m)-apple-macosx13.0" # Run native tests in French via process-only user-default arguments. CI runs # in English, so this catches tests that depend on the machine's language. From 2515345ac304e71f862d4fe92b08f1a7412b173a Mon Sep 17 00:00:00 2001 From: Chai Pin Zheng Date: Sat, 17 Oct 2026 17:52:30 +0800 Subject: [PATCH 3/5] release(fix): keep the npm token out of argv and make checks fail closed - set-npm-token.sh sends the token to npm on stdin instead of env's argument list; the test now logs env's arguments. - The AUR clean build stops on namcap errors (errexit ignores `!`). - The Pages check reads the whole site before grepping it. - The outreach tracker allows plain HTTP only for exactly 127.0.0.1, and list --owned false lists unowned targets. Co-Authored-By: Claude Opus 5.5 --- scripts/aur/verify.sh | 6 +++++- scripts/outreach-tracker.mjs | 12 ++++++++++-- scripts/release/deploy-pages.sh | 6 ++++-- scripts/release/set-npm-token.sh | 8 ++++++-- test/outreach/tracker-test.mjs | 13 +++++++++++++ test/packaging/aur-test.mjs | 3 +++ test/release/npm-token-test.sh | 7 +++++++ 7 files changed, 48 insertions(+), 7 deletions(-) diff --git a/scripts/aur/verify.sh b/scripts/aur/verify.sh index 93489c3..7298c5a 100755 --- a/scripts/aur/verify.sh +++ b/scripts/aur/verify.sh @@ -147,7 +147,11 @@ done [[ -n "$package_file" && -f "$package_file" ]] namcap PKGBUILD | tee namcap-pkgbuild.log namcap "$package_file" | tee namcap-package.log -! grep -Eq '(^|[[:space:]])E:' namcap-pkgbuild.log namcap-package.log +# errexit ignores a command negated with !, so an E: line must stop explicitly. +if grep -Eq '(^|[[:space:]])E:' namcap-pkgbuild.log namcap-package.log; then + printf 'namcap reported errors.\n' >&2 + exit 1 +fi package_root="$(mktemp -d)" trap 'rm -rf "$package_root"' EXIT diff --git a/scripts/outreach-tracker.mjs b/scripts/outreach-tracker.mjs index 0480091..1ffc867 100644 --- a/scripts/outreach-tracker.mjs +++ b/scripts/outreach-tracker.mjs @@ -68,9 +68,16 @@ function fail(code, message) { function dataApiUrl() { const value = process.env.NEON_DATA_API_URL?.replace(/\/$/, ''); if (!value) fail(EXIT.USAGE, 'No Neon endpoint: set NEON_DATA_API_URL.'); - if (!/^https:\/\//.test(value) && !/^http:\/\/127\.0\.0\.1(?::\d+)?/.test(value)) { + let parsed; + try { + parsed = new URL(value); + } catch { fail(EXIT.USAGE, 'NEON_DATA_API_URL must use HTTPS.'); } + // Plain HTTP is only for a local test server; a prefix match would also + // accept hosts such as 127.0.0.1.example.com and send the JWT in the clear. + const local = parsed.protocol === 'http:' && parsed.hostname === '127.0.0.1'; + if (parsed.protocol !== 'https:' && !local) fail(EXIT.USAGE, 'NEON_DATA_API_URL must use HTTPS.'); return value; } @@ -343,7 +350,8 @@ async function cmdList(flags) { p_status: flags.status || null, p_priority: flags.priority || null, p_channel: flags.channel || null, - p_owned: flags.owned ? true : null, + // A bare --owned means true; --owned false must not select owned targets. + p_owned: flags.owned === undefined ? null : asBool(flags.owned), }, { paginate: true }); const records = rows.map(targetFields); if (flags.json) process.stdout.write(`${JSON.stringify(records, null, 2)}\n`); diff --git a/scripts/release/deploy-pages.sh b/scripts/release/deploy-pages.sh index e013b01..005f812 100755 --- a/scripts/release/deploy-pages.sh +++ b/scripts/release/deploy-pages.sh @@ -35,8 +35,10 @@ gh run watch "$run_id" --repo "$GITHUB_REPOSITORY" --exit-status site_url="https://ducksss.github.io/codex-profiles/" deployed=false for _attempt in {1..30}; do - if curl --connect-timeout 10 --max-time 30 -fsSL "$site_url" \ - | grep -F "v$V" >/dev/null; then + # Read the whole page first: grep can stop at the first match, and under + # pipefail curl's resulting write error would fail a deployed site. + page="$(curl --connect-timeout 10 --max-time 30 -fsSL "$site_url")" || page="" + if grep -Fq "v$V" <<< "$page"; then deployed=true break fi diff --git a/scripts/release/set-npm-token.sh b/scripts/release/set-npm-token.sh index 7e96f0f..a0b85cb 100755 --- a/scripts/release/set-npm-token.sh +++ b/scripts/release/set-npm-token.sh @@ -88,10 +88,14 @@ npm_with_token() { cd "$work_dir" # A cleared environment keeps npm_config_* variables from overriding the # isolated configuration; only the path, proxies and CA bundle carry over. - env -i "${npm_environment[@]}" NODE_AUTH_TOKEN="$token" \ + # env is a separate program, so the token travels on stdin (printf is a + # builtin) and the child shell exports it; it is never an argument. + # shellcheck disable=SC2016 # the child shell expands its own variables + printf '%s\n' "$token" | env -i "${npm_environment[@]}" \ NPM_CONFIG_USERCONFIG="$work_dir/npmrc" \ NPM_CONFIG_GLOBALCONFIG="$work_dir/global-npmrc" \ - npm "$@" --registry "$registry" 2> /dev/null + /bin/sh -c 'IFS= read -r NODE_AUTH_TOKEN || exit 1; export NODE_AUTH_TOKEN; exec npm "$@" < /dev/null' \ + sh "$@" --registry "$registry" 2> /dev/null ) } diff --git a/test/outreach/tracker-test.mjs b/test/outreach/tracker-test.mjs index 7921a6b..450e251 100644 --- a/test/outreach/tracker-test.mjs +++ b/test/outreach/tracker-test.mjs @@ -236,6 +236,19 @@ try { const listed = await run(['list', '--json']); assert.equal(listed.status, 0, listed.stderr); assert.deepEqual(JSON.parse(listed.stdout).map(({ Key }) => Key), ['alpha', 'quote"slash\\key']); + const unowned = await run(['list', '--owned', 'false', '--json']); + assert.equal(unowned.status, 0, unowned.stderr); + assert.deepEqual(JSON.parse(unowned.stdout).map(({ Key }) => Key), ['alpha', 'quote"slash\\key']); + const owned = await run(['list', '--owned', '--json']); + assert.equal(owned.status, 0, owned.stderr); + assert.deepEqual(JSON.parse(owned.stdout), []); + + // Plain HTTP is only for the exact loopback address, never a lookalike host. + for (const url of ['http://127.0.0.1.attacker.example/rest/v1', 'http://example.com/rest/v1', 'not a url']) { + const insecure = await run(['list', '--json'], { NEON_DATA_API_URL: url }); + assert.equal(insecure.status, 2, `${url} was accepted`); + assert.match(insecure.stderr, /must use HTTPS/); + } const escaped = await run(['get', 'quote"slash\\key', '--json']); assert.equal(escaped.status, 0, escaped.stderr); diff --git a/test/packaging/aur-test.mjs b/test/packaging/aur-test.mjs index 7b04952..826b6a2 100644 --- a/test/packaging/aur-test.mjs +++ b/test/packaging/aur-test.mjs @@ -479,9 +479,12 @@ for (const contract of [ "--platform linux/amd64", "DisableSandbox", '[[ "$(readlink "$alias")" == codex-profile ]]', + "namcap reported errors", ]) { assert.ok(verifySource.includes(contract), `container verifier should contain ${contract}`); } +// errexit ignores a command negated with !, so a bare `! check` never fails. +assert.doesNotMatch(verifySource, /^\s*! /m, "verify.sh must gate checks with if/exit, not a bare `!`"); const runbook = readFileSync(runbookPath, "utf8"); assert.ok(runbook.includes("scripts/aur/prepare.sh"), "runbook should call prepare.sh"); diff --git a/test/release/npm-token-test.sh b/test/release/npm-token-test.sh index 858e977..64c1749 100644 --- a/test/release/npm-token-test.sh +++ b/test/release/npm-token-test.sh @@ -37,6 +37,13 @@ case "$1" in esac FAKE_NPM +# env is a separate program: anything in its arguments is visible in ps and +# exec audit logs, so it is logged like npm and gh. +write_command_shim "$fake_bin/env" <<'FAKE_ENV' +printf 'env %s\n' "$*" >> "$TEST_LOG" +exec /usr/bin/env "$@" +FAKE_ENV + write_command_shim "$fake_bin/gh" <<'FAKE_GH' printf 'gh %s\n' "$*" >> "$TEST_LOG" case "$1 $2" in From 6c0785b3bab4470ea255ce6f705f7b2487579833 Mon Sep 17 00:00:00 2001 From: Chai Pin Zheng Date: Sat, 17 Oct 2026 17:52:34 +0800 Subject: [PATCH 4/5] docs(docs): correct drift found in the full repository review - Name the USAGE.md command reference in the sync rules and note that main must not move during a release. - Complete the network model (upgrade's GitHub Releases request; the update check follows whether output is a terminal). - Describe clone-config's text matching, setup defaults, plain doctor and usage exit status, list output and launcher targets accurately, and add maintenance commands to llms.txt. - Stop shipping the unused docs/welcome.svg in the npm package. - Record the review's fixes under Unreleased. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 16 ++++++---- CHANGELOG.md | 72 ++++++++++++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 10 ++++-- README.md | 4 +-- SECURITY.md | 23 ++++++++------ USAGE.md | 22 ++++++++----- docs/index.html | 2 +- docs/llms.txt | 25 +++++++++++++-- package.json | 1 - test/site/geo-test.mjs | 2 +- 10 files changed, 143 insertions(+), 34 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 370ddc5..6bf9d32 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,8 +24,10 @@ It is community-maintained and is **not** an official OpenAI project. - `raycast/` — optional Raycast extension (TypeScript, its own npm dependencies); delegates every profile operation to the `codex-profile` CLI (a standalone install or the menu-bar app's bundled copy) and never reads - auth data. Background calls use argument lists; Terminal launches go through - the user's shell with AppleScript `quoted form of`. It is outside the npm + auth data. Background calls use argument lists; Terminal launches pass the + user's shell one command whose values are single-quoted so sh, bash, zsh + and fish all read them literally (AppleScript `quoted form of` is not + fish-safe). It is outside the npm package and `make check`; verify it with `npm run typecheck`, `npm test` and `npx prettier --check .` inside `raycast/`. `npm run lint` fails until `author` in `raycast/package.json` is a real Raycast username, so use @@ -97,9 +99,10 @@ Profile-to-path mapping: `default -> ~/.codex`; any other name ` -> ~/.codex- Shared test helpers must provide infrastructure rather than product policy. - Match the existing Bash style in `bin/codex-profile`: `set -euo pipefail`, `command_*` functions for subcommands, and the `die`/`note` helpers. -- If you change the command surface, update `usage()` in `bin/codex-profile`, - the README command reference, the shell completion generators, and - `docs/llms.txt` so all four stay in sync. +- If you change the command surface, update `usage()` and the per-command help + in `bin/codex-profile`, the command reference in `USAGE.md` (the README only + links to it), the shell completion generators, and `docs/llms.txt` so they + stay in sync. - Bump the version together in `bin/codex-profile`, `package.json`, both `package-lock.json` version fields, `docs/index.html`, `packaging/aur/PKGBUILD`, and `packaging/aur/.SRCINFO`. CI and the GEO test @@ -140,7 +143,8 @@ link. per-profile Electron user data. The tool never reads, copies, prints, parses, compares, or migrates `auth.json` tokens or ChatGPT cookies. - `clone-config` copies only an allowlist of non-secret root config files and - refuses sensitive-looking key names. + refuses any file containing sensitive-looking text (key, token, secret and + similar words, URL credentials, common token prefixes). - `init --share-with` links only the documented configuration allowlist. It keeps auth, sessions, logs, Electron data, caches, skills, and connector/app state separate and never reads or copies authentication data; linked config diff --git a/CHANGELOG.md b/CHANGELOG.md index 3775762..c541d14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,78 @@ and this project follows semantic versioning for tagged releases. percent sign. Hover and keyboard selection use an inset rounded plate, rules start at the title column and step aside beside it, and space rather than rules separates groups. +- The npm package no longer ships the unused `docs/welcome.svg`. + +### Fixed + +- `status --json` stays valid JSON when a profile's status fails or the Codex + CLI is missing, and rejects invalid profile names instead of reporting them. + Plain `status` reports a missing Codex CLI once instead of running an empty + command for every profile. +- `launcher create` no longer reports success when a build step fails, for + example from a copy that is not executable. Its default name now fits profile + names that end in `.`, `-` or `_` or are very long; `--force` can change only + the letter case of a name on a case-insensitive volume; and an interrupt no + longer leaves a half-built bundle in `~/Applications`. +- `remove` refuses a differently cased spelling of a profile on a + case-insensitive volume, which skipped its shared-configuration and + workspace-binding checks. +- An exported `CDPATH` no longer breaks relative workspace paths. `workspace + unbind` resolves a deleted folder through its surviving parent, as `bind` + recorded it (for example `/tmp` or a symlinked projects folder). `app` never + passes a workspace starting with `-` to `open` as an option, and + `app-instance` or `app --instance` without a profile opens the picker as + documented. +- `doctor --json` reports unreadable workspace state instead of exiting with + no output. +- Two commands reclaiming the same stale state lock can no longer both + proceed, and a lock with no recorded owner explains how to recover. +- `env` and `use` refuse a symlinked profile home, as every other command does. +- The Bash and Zsh `--prompt` indicator removes stale `[codex:…]` prefixes + that tools such as Python venv restore with their saved prompt. +- `upgrade --prefix` resolves a relative prefix from the current directory + instead of the upgrade cache, and `upgrade --ref` refuses a name that is not + a commit instead of restoring a path with that name. +- Zsh completions saved as `_codex-profile` complete on every Tab instead of + every other one, and `doctor` completion offers `-j`. Help lists the + update-check environment variables. +- Menu-bar low-quota alerts no longer take a reading that is still refreshing + as the baseline when another profile's reading arrives first, which could + notify as soon as alerts were turned on. +- Raycast: a usage timeout is reported as a timeout, Open Workspace clears a + profile filter whose last binding was removed, and copied commands use the + `codex-profile` the extension found, including the menu-bar app's copy. +- Release automation: the AUR clean build now fails on namcap errors, and the + Pages check reads the whole site before searching it. The outreach tracker + accepts plain HTTP only for exactly `127.0.0.1`, and `list --owned false` + lists unowned targets. + +### Security + +- Terminal launches from the menu-bar app and Raycast quote profile names and + folder paths so fish reads them literally. AppleScript's `quoted form of` is + POSIX-only, so under fish a folder name containing a backslash and a quote + could run the rest of the name as commands. +- `clone-config` also refuses files with names such as `BRAVE_KEY` or + `GITHUB_PAT`, `key` or `dsn` entries, credentials in URLs and common token + prefixes, and its message and documentation now say it matches text anywhere + in the file. +- `scripts/release/set-npm-token.sh` hands the token to npm on standard input + instead of `env`'s argument list, where `ps` and exec audit logs could see it. + +### Documented + +- The network model now includes the GitHub Releases request that `upgrade` + and `upgrade --dry-run` make, and the update check is described by whether + output is a terminal. Corrected the `setup` defaults, plain `doctor` exit + status, `list` output, launcher target and `usage` exit status, added + maintenance commands to `llms.txt`, named the `USAGE.md` command reference in + the sync rules, and noted that `main` must not move during a release. + +### Tests + +- Native tests tag their throwaway defaults suites and remove them afterwards, + instead of leaving an empty plist in `~/Library/Preferences` on every run. ## 1.3.0 - 2026-10-12 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5dedb52..7399084 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,8 +44,9 @@ killing, token copying, or cookie migration. - Keep human and JSON output explicit about whether a fact concerns Codex-local state or a ChatGPT Desktop window. - Add regression tests for every observable behavior change. -- Update CLI help, README, completions, `docs/llms.txt`, structured data, and - `CHANGELOG.md` together when the public surface changes. +- Update CLI help, the `USAGE.md` command reference, README, completions, + `docs/llms.txt`, structured data, and `CHANGELOG.md` together when the + public surface changes. - Keep `bin/codex-profile`, npm/package-lock, docs, PKGBUILD, and `.SRCINFO` versions in sync. A release also requires a dated changelog section. @@ -162,7 +163,10 @@ the release artifact. Installation verification uses a fresh prefix and checks `help` and `version` through both command aliases. On either timeout, investigate npm review notifications and registry status, then rerun the same immutable release once the exact artifact is publicly installable. Never bypass checks or -move tags to recover. +move tags to recover. Because every rerun also requires `origin/main` to still +be the verified commit, do not merge to `main` until the release finishes; if +`main` moves after the tag is pushed, the rerun stops at that check and the +remaining channels need a new patch release. The workflow then verifies the exact GitHub Release tag, downloads `install.sh` from that immutable tag, and exercises its public `releases/latest` path in diff --git a/README.md b/README.md index d405510..ace9b73 100644 --- a/README.md +++ b/README.md @@ -206,8 +206,8 @@ codex-profile cli work ``` `setup` guides you through profile creation and offers sign-in, project -binding and shell integration. Optional extras default to no, and shell -startup changes are shown for approval first. For scripts, use `init`, +binding, a macOS launcher and shell integration. Sign-in defaults to yes and +the others to no; shell startup changes are shown for approval first. For scripts, use `init`, `login` and `cli` separately. Install **`codex-profile`** (singular). It provides both `codex-profile` and diff --git a/SECURITY.md b/SECURITY.md index ef05ec5..97eadf9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -75,8 +75,10 @@ regulated, privileged, or adversarial separation. `clone-config` considers only an allowlist of root-level non-auth files. It never copies `auth.json`, sessions, plugins, logs, caches, Electron data, or -directories, and it refuses sensitive-looking keys. The heuristic is a safety -guard, not a proof that arbitrary text is non-secret. Review source files +directories, and it refuses a file containing sensitive-looking text anywhere, +prose included: words such as auth, token, secret, password or key, credentials +in URLs, and common token prefixes. The heuristic is a safety guard, not a +proof that arbitrary text is non-secret. Review source files before copying them between trust domains. ## Configuration linking @@ -98,13 +100,16 @@ trust domains. The wrapper itself performs network access only for these project-maintenance features: -- `upgrade` fetches the configured source repository. A non-default repository - can execute code during installation; use only sources you trust and inspect - `upgrade --dry-run` first. -- Interactive terminal runs may make one anonymous HTTPS request to the npm - registry at most once per day to compare versions. Scripts, pipes, CI, and - JSON output do not perform this check. Disable it with - `CODEX_PROFILE_NO_UPDATE_CHECK=1` or `DO_NOT_TRACK=1`. +- `upgrade` without `--ref`, including `upgrade --dry-run`, first asks the + GitHub Releases API for the latest release, then fetches the configured + source repository. A non-default repository can execute code during + installation; use only sources you trust and inspect `upgrade --dry-run` + first. +- Runs whose standard output is a terminal may make one anonymous HTTPS + request to the npm registry at most once per day to compare versions. Pipes, + captured output, runs without a terminal such as most CI, and JSON output do + not perform this check. Disable it with `CODEX_PROFILE_NO_UPDATE_CHECK=1` or + `DO_NOT_TRACK=1`. Codex CLI and the ChatGPT app make their own network requests under OpenAI's behavior and policies. Those requests are outside this wrapper's network diff --git a/USAGE.md b/USAGE.md index 0c55b03..afd1ec4 100644 --- a/USAGE.md +++ b/USAGE.md @@ -348,7 +348,9 @@ when not `ok`, and `windows` with `duration_mins`, `used_percent`, `remaining_percent` and `resets_at` (Unix seconds); durations and reset times may be `null`. The command reports quota only: it never switches profiles. -Ordinary `doctor` remains informational. `doctor --check` exits nonzero when +Ordinary `doctor` does not fail on unhealthy local state; it exits nonzero only +when a profile's `codex login status` fails unexpectedly, as `status` does. +`doctor --check` exits nonzero when the CLI is missing, status collection fails, workspace state is invalid or stale, or private profile state is linked; JSON includes top-level `healthy` and workspace `schema_version` fields for automation. @@ -392,9 +394,10 @@ set `CODEX_PROFILE_LAUNCHER_ROOT` to use another directory. Creation derives a deterministically tinted icon from the installed ChatGPT artwork on the local Mac. The project does not redistribute that artwork. -Each generated app is a small unsigned shell launcher. It calls -`codex-profile app ` and leaves the installed, signed ChatGPT bundle -untouched. Its custom name and icon appear on the launcher in Finder, +Each generated app is a small unsigned shell launcher. It runs `app ` +through the absolute path of the `codex-profile` that created it (recreate it +with `--force` after moving or reinstalling `codex-profile` elsewhere) and +leaves the installed, signed ChatGPT bundle untouched. Its custom name and icon appear on the launcher in Finder, Launchpad, and when pinned to the Dock. After launch, the active ChatGPT process keeps ChatGPT's native name and icon because macOS is running the original signed app. @@ -588,7 +591,9 @@ codex-profile clone-config personal work --force Only root-level `config.toml` and `AGENTS.md` are eligible. The command never copies `auth.json`, sessions, plugins, logs, caches, Electron data, or -directories, and it refuses sensitive-looking configuration keys. +directories, and it refuses a file containing sensitive-looking text anywhere, +including prose: words such as auth, token, secret, password or key, +credentials in URLs, and common token prefixes. ### Upgrade a source installation @@ -767,8 +772,9 @@ codex-profile cli work --help ## Update checks -Interactive terminal runs check the npm registry at most once per day and use -a local cache. Scripts, pipes, CI, and JSON output remain quiet. The request +Runs whose standard output is a terminal check the npm registry at most once +per day and use a local cache. Pipes, captured output, runs without a terminal +such as most CI, and JSON output remain quiet. The request contains no profile data; disable it with `CODEX_PROFILE_NO_UPDATE_CHECK=1` or `DO_NOT_TRACK=1`. See [SECURITY.md](SECURITY.md) for the complete network model. @@ -777,7 +783,7 @@ contains no profile data; disable it with `CODEX_PROFILE_NO_UPDATE_CHECK=1` or CLI-oriented commands and launcher inspection are tested on macOS and Ubuntu/Linux: ```text -cli login init setup detach remove workspace run status path env use logs clone-config list doctor completions shell-init upgrade version help +cli login init setup detach remove workspace run status usage path env use logs clone-config list doctor completions shell-init upgrade version help ``` `app` and `launcher create` are macOS-only. They detect the installed integrated `ChatGPT.app` and diff --git a/docs/index.html b/docs/index.html index 9779718..db1ddb6 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1354,7 +1354,7 @@

What it does

Safe config cloning -

clone-config copies only known non-secret root config files and refuses sensitive-looking keys.

+

clone-config copies only known non-secret root config files and refuses any file with sensitive-looking text.

diff --git a/docs/llms.txt b/docs/llms.txt index d998e15..61d2be1 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -89,7 +89,8 @@ homes and, on macOS, named ChatGPT windows with separate local state. Codex home, bound workspace paths, and managed launcher availability and path. It identifies missing or stale launchers and reports launchers as unsupported on non-macOS systems. It reads local metadata without probing login status or - reading authentication data. Plain `list` keeps its existing tabular output. + reading authentication data. Plain `list` prints one initialized profile name + per line. - `workspace bind ` stores a private local canonical-path binding. The nearest bound ancestor wins; no file is added to the project. - `run` uses the current directory's binding for Codex CLI, while `run --app` @@ -274,8 +275,10 @@ are blue, green, teal, purple, pink, red, orange, and graphite. The command derives a deterministic tint from the installed ChatGPT icon locally and does not redistribute OpenAI artwork. -The generated app calls `codex-profile app ` and never copies, -modifies, or re-signs the installed ChatGPT bundle. Its custom identity is +The generated app runs `app ` through the absolute path of the +`codex-profile` that created it, so recreate it with `--force` after moving or +reinstalling `codex-profile` elsewhere. It never copies, modifies, or re-signs +the installed ChatGPT bundle. Its custom identity is visible in Finder, Launchpad, and on a pinned Dock launcher. The active ChatGPT process retains its native name and icon. `launcher list [--json]`, `launcher path `, and `launcher remove [--yes]` inspect and remove only @@ -285,6 +288,22 @@ managed launchers; profile authentication and Electron data are preserved. Changing a managed launcher's name or color requires `--force`. Profile removal refuses while its managed launcher exists. +## Profile maintenance + +- `codex-profile path ` prints the profile's `CODEX_HOME` without + creating it. +- `codex-profile remove [--yes]` permanently deletes that profile's + home after a typed confirmation (or `--yes`) and clears its workspace + bindings. It refuses while another profile links its configuration or while + its managed launcher exists. +- `codex-profile logs [--path|--tail [lines]]` prints the named + ChatGPT window's desktop log, its path, or its last lines (default 50). +- `codex-profile clone-config [--force]` copies only root + `config.toml` and `AGENTS.md`, never authentication or session data. It + refuses symlinks and any file containing sensitive-looking text, and needs + `--force` to overwrite an existing target file. +- `codex-profile version` prints the installed version. + ## In-shell activation - `codex-profile env ` prints shell code that exports `CODEX_HOME` and diff --git a/package.json b/package.json index d820549..aa936a5 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,6 @@ "LICENSE", "README.md", "USAGE.md", - "docs/welcome.svg", "SECURITY.md" ], "os": [ diff --git a/test/site/geo-test.mjs b/test/site/geo-test.mjs index 481f5f8..486004d 100644 --- a/test/site/geo-test.mjs +++ b/test/site/geo-test.mjs @@ -324,6 +324,6 @@ for (const document of ['README.md', 'USAGE.md']) { } } } -for (const asset of ['USAGE.md', 'docs/welcome.svg']) { +for (const asset of ['USAGE.md']) { assert.ok(packageJson.files.includes(asset), `npm package must include README dependency ${asset}`); } From a85b1c9152850256bf82f6f524a7ffc3eb10ca32 Mon Sep 17 00:00:00 2001 From: Chai Pin Zheng Date: Sat, 17 Oct 2026 18:02:29 +0800 Subject: [PATCH 5/5] cli(fix): ignore symlinked aliases in remove's spelling check - remove no longer refuses a profile because a symlinked alias home (for example .codex-alias -> .codex-work) resolves to the same folder; only real directories can be another spelling of it. - Raycast Open Workspace forgets a profile filter once a finished load confirms the profile has no bindings, so a later binding cannot silently reapply it. Co-Authored-By: Claude Opus 5.5 --- bin/codex-profile | 2 ++ raycast/src/open-workspace.tsx | 8 +++++++- test/cli/profiles-test.sh | 17 +++++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/bin/codex-profile b/bin/codex-profile index a8bfe30..bbfcc81 100755 --- a/bin/codex-profile +++ b/bin/codex-profile @@ -2783,6 +2783,8 @@ command_remove() { if [[ -d "$codex_home" ]]; then for stored in $(discover_profiles yes); do [[ "$stored" != "$profile" ]] || break + # A symlinked home is an alias, not another spelling of this profile. + [[ ! -L "$(codex_home_for_profile "$stored")" ]] || continue if [[ "$(codex_home_for_profile "$stored")" -ef "$codex_home" ]]; then die "Profile '$profile' is stored as '$stored'; run '$PROGRAM remove $stored' instead." fi diff --git a/raycast/src/open-workspace.tsx b/raycast/src/open-workspace.tsx index 3a999e1..7ebbb9f 100644 --- a/raycast/src/open-workspace.tsx +++ b/raycast/src/open-workspace.tsx @@ -1,6 +1,6 @@ import { Action, ActionPanel, Color, Icon, Keyboard, List } from "@raycast/api"; import { useCachedPromise, useFrecencySorting } from "@raycast/utils"; -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { HOME, loadWorkspaces } from "./cli"; import { bindingProblem, displayPath, folderName, type WorkspaceBinding } from "./lib/profile"; import { shellQuote } from "./lib/terminal"; @@ -29,6 +29,12 @@ function WorkspaceList(props: { cli: string }) { const profiles = [...new Set(bindings.map((binding) => binding.profile))].sort((a, b) => a.localeCompare(b)); // A refresh can remove the filtered profile's last binding and hide the dropdown. const activeFilter = profiles.includes(profileFilter) ? profileFilter : ALL_PROFILES; + const filteredProfileGone = !isLoading && data !== undefined && activeFilter !== profileFilter; + // Forget that choice once a finished load confirms it is gone, so a later + // binding for the same profile does not silently filter the list again. + useEffect(() => { + if (filteredProfileGone) setProfileFilter(ALL_PROFILES); + }, [filteredProfileGone]); const visible = sorted.filter((binding) => activeFilter === ALL_PROFILES || binding.profile === activeFilter); return ( diff --git a/test/cli/profiles-test.sh b/test/cli/profiles-test.sh index c90f3c2..b8a19ff 100755 --- a/test/cli/profiles-test.sh +++ b/test/cli/profiles-test.sh @@ -421,6 +421,22 @@ test_remove_yes_deletes_profiles_named_like_common_aliases() { rm -rf "$tmp" } +test_remove_is_not_blocked_by_a_symlinked_alias_home() { + local tmp + tmp="$(mktemp -d)" + mkdir -p "$tmp/home/.codex-work" + # The alias sorts first and resolves to the same directory. + ln -s "$tmp/home/.codex-work" "$tmp/home/.codex-alias" + + run_cmd env HOME="$tmp/home" "$SCRIPT" remove work --yes + + assert_status 0 + assert_not_contains "is stored as" + [[ ! -e "$tmp/home/.codex-work" ]] || fail "remove kept the profile home behind a symlinked alias" + + rm -rf "$tmp" +} + test_remove_refuses_profiles_with_shared_configuration_dependents() { local tmp source_home target_home tmp="$(mktemp -d)" @@ -608,6 +624,7 @@ test_remove_aborts_when_confirmation_does_not_match test_remove_yes_deletes_profile_home test_remove_yes_deletes_profiles_named_like_common_aliases test_remove_refuses_profiles_with_shared_configuration_dependents +test_remove_is_not_blocked_by_a_symlinked_alias_home test_profile_home_symlinks_are_refused test_clone_config_copies_safe_files_and_never_auth_files test_clone_config_refuses_sensitive_looking_config