diff --git a/README.md b/README.md index 22be32e..e27ad51 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,14 @@ sloptrap runs the OpenAI Codex CLI inside a container with a predictable and loc > Tip: set `SLOPTRAP_CONTAINER_ENGINE=` if you need to override the default Podman requirement. +### macOS setup + +sloptrap targets GNU userland. On macOS, install the GNU tools via Homebrew and the launcher will prepend their `gnubin` paths automatically: + +``` +brew install coreutils gnu-tar jq +``` + ## Quick Start 1. Place `sloptrap` somewhere on your PATH/shared drive (the helper Dockerfile and Codex binary are bundled and downloaded automatically). diff --git a/sloptrap b/sloptrap index db52bec..9cee9a0 100755 --- a/sloptrap +++ b/sloptrap @@ -2,7 +2,57 @@ # sloptrap set -euo pipefail -require_cmd() { command -v "$1" >/dev/null 2>&1 || error "'$1' is required"; } +IS_MAC=false +if [[ $(uname -s 2>/dev/null) == "Darwin" ]]; then + IS_MAC=true + mac_gnu_bins=( + /opt/homebrew/opt/coreutils/libexec/gnubin + /usr/local/opt/coreutils/libexec/gnubin + /opt/homebrew/opt/gnu-tar/libexec/gnubin + /usr/local/opt/gnu-tar/libexec/gnubin + ) + for bin_dir in "${mac_gnu_bins[@]}"; do + [[ -d $bin_dir ]] || continue + case ":$PATH:" in + *":$bin_dir:"*) + ;; + *) + path_head=${PATH%%:*} + path_rest="" + if [[ $PATH == *:* ]]; then + path_rest=${PATH#*:} + fi + case "$path_head" in + /tmp/*|/var/folders/*|"$HOME"/*) + if [[ -n $path_rest ]]; then + PATH="$path_head:$bin_dir:$path_rest" + else + PATH="$path_head:$bin_dir" + fi + ;; + *) + PATH="$bin_dir:$PATH" + ;; + esac + unset -v path_head path_rest + ;; + esac + done + export PATH +fi + +REQUIRED_BREW_PKGS="coreutils gnu-tar jq" + +require_cmd() { + if command -v "$1" >/dev/null 2>&1; then + return 0 + fi + if $IS_MAC; then + error "'$1' is required; install with: brew install $REQUIRED_BREW_PKGS" + else + error "'$1' is required" + fi +} for c in curl tar sha256sum realpath jq; do require_cmd "$c"; done COLOR_TEXT=$'\033[38;5;247m' @@ -147,6 +197,26 @@ trim() { printf '%s' "$value" } +resolve_path_relaxed() { + local candidate=$1 + local resolved + if resolved=$(realpath -m "$candidate" 2>/dev/null); then + printf '%s' "$resolved" + return 0 + fi + return 1 +} + +resolve_path_strict() { + local candidate=$1 + local resolved + if resolved=$(resolve_path_relaxed "$candidate"); then + printf '%s' "$resolved" + return 0 + fi + error "failed to resolve path '$candidate'; install GNU coreutils for realpath support" +} + declare -A MANIFEST=() declare -a SLOPTRAP_IGNORE_ENTRIES=() declare -a IGNORE_MOUNT_ARGS=() @@ -181,11 +251,11 @@ cleanup_ignore_stub_dir() { [[ -n $helper_root && -n $stub_base ]] || return 0 [[ -d $stub_base ]] || return 0 local resolved_helper resolved_stub - if ! resolved_helper=$(realpath -m "$helper_root") 2>/dev/null; then + if ! resolved_helper=$(resolve_path_relaxed "$helper_root"); then warn "failed to resolve helper root '$helper_root' during cleanup" return 0 fi - if ! resolved_stub=$(realpath -m "$stub_base") 2>/dev/null; then + if ! resolved_stub=$(resolve_path_relaxed "$stub_base"); then warn "failed to resolve helper stub '$stub_base' during cleanup" return 0 fi @@ -269,6 +339,18 @@ validate_basename() { [[ $name =~ ^[A-Za-z0-9._+-]+$ ]] || error "invalid basename '$name'" } +sanitize_engine_name() { + local name=$1 + local lowered=${name,,} + if [[ $lowered != "$name" ]]; then + warn "normalizing name '$name' to '$lowered' for container engine compatibility" + fi + if [[ ! $lowered =~ ^[a-z0-9_.-]+$ ]]; then + error "engine name '$name' is invalid after normalization" + fi + printf '%s' "$lowered" +} + prepare_build_context() { if [[ -n $SLOPTRAP_BUILD_CONTEXT && -d $SLOPTRAP_BUILD_CONTEXT ]]; then return 0 @@ -282,6 +364,9 @@ prepare_build_context() { select_codex_home() { local preferred="$HOME/.codex" + if [[ -L $preferred ]]; then + error "Codex home '$preferred' must not be a symlink" + fi if [[ -e $preferred && ! -d $preferred ]]; then error "expected Codex home '$preferred' to be a directory" fi @@ -298,7 +383,7 @@ select_codex_home() { assert_path_within_code_dir() { local candidate=$1 local resolved - resolved=$(realpath -m "$candidate") + resolved=$(resolve_path_strict "$candidate") if [[ $resolved != "$CODE_DIR" && $resolved != "$CODE_DIR/"* ]]; then error "path '$candidate' escapes project root '$CODE_DIR'" fi @@ -345,7 +430,7 @@ sanitize_ignore_rel() { error "$source: .sloptrapignore entry '$original' contains control characters" fi local resolved - resolved=$(realpath -m "$CODE_DIR/$rel") + resolved=$(resolve_path_strict "$CODE_DIR/$rel") if [[ $resolved != "$CODE_DIR" && $resolved != "$CODE_DIR/"* ]]; then error "$source: .sloptrapignore entry '$original' resolves outside the project root" fi @@ -529,11 +614,12 @@ ensure_safe_for_make() { validate_package_list() { local key=$1 local raw=$2 + local source=${3:-$MANIFEST_PATH} [[ -z $raw ]] && return 0 local token for token in $raw; do if [[ ! $token =~ ^[A-Za-z0-9+.-]+$ ]]; then - error "$MANIFEST_PATH: invalid package name '$token' in '$key'" + error "$source: invalid package name '$token' in '$key'" fi done } @@ -552,7 +638,11 @@ detect_container_engine() { printf 'podman' return 0 fi - error "podman is required but was not found in PATH; set SLOPTRAP_CONTAINER_ENGINE to override explicitly" + if command -v docker >/dev/null 2>&1; then + printf 'docker' + return 0 + fi + error "container engine not found in PATH; install podman (preferred) or docker, or set SLOPTRAP_CONTAINER_ENGINE explicitly" } parse_manifest() { @@ -592,6 +682,8 @@ print_config() { info_line "container_name=%s\n" "$SLOPTRAP_CONTAINER_NAME" info_line "codex_home=%s\n" "$CODEX_HOME_HOST" info_line "codex_home_bootstrap=%s\n" "$CODEX_HOME_BOOTSTRAP" + info_line "codex_archive=%s\n" "$SLOPTRAP_CODEX_ARCHIVE" + info_line "codex_url=%s\n" "$SLOPTRAP_CODEX_URL" info_line "needs_login=%s\n" "$NEED_LOGIN" info_line "codex_args=%s\n" "$CODEX_ARGS_DISPLAY" info_line "ignore_stub_base=%s\n" "$IGNORE_STUB_BASE" @@ -626,6 +718,7 @@ SLOPTRAP_PACKAGES_BASE="" SLOPTRAP_PACKAGES_EXTRA_RESOLVED="" SLOPTRAP_CODEX_BIN_NAME="" SLOPTRAP_CODEX_URL="" +SLOPTRAP_CODEX_ARCHIVE="" SLOPTRAP_CODEX_HOME_CONT="" SLOPTRAP_SECURITY_OPTS_EXTRA="" SLOPTRAP_VOLUME_LABEL="" @@ -650,6 +743,29 @@ get_env_default() { fi } +validate_codex_archive_name() { + local name=$1 + [[ $name =~ ^codex-[A-Za-z0-9_.-]+$ ]] || error "invalid Codex archive name '$name'" +} + +detect_codex_archive_name() { + local os arch codex_os codex_arch + os=$(uname -s 2>/dev/null || true) + arch=$(uname -m 2>/dev/null || true) + [[ -n $os ]] || error "failed to detect host OS for Codex download" + [[ -n $arch ]] || error "failed to detect host architecture for Codex download" + case "$os" in + Linux|Darwin) codex_os="unknown-linux-gnu" ;; # Codex runs inside a Debian-based image + *) error "unsupported host OS '$os' for Codex download" ;; + esac + case "$arch" in + x86_64|amd64) codex_arch="x86_64" ;; + arm64|aarch64) codex_arch="aarch64" ;; + *) error "unsupported host architecture '$arch' for Codex download" ;; + esac + printf 'codex-%s-%s' "$codex_arch" "$codex_os" +} + resolve_container_workdir() { if [[ -z ${SLOPTRAP_WORKDIR:-} ]]; then SLOPTRAP_WORKDIR=$(get_env_default "SLOPTRAP_WORKDIR" "/workspace") @@ -690,6 +806,8 @@ ensure_codex_home_dir() { fetch_latest_codex_digest() { local api_url="https://api.github.com/repos/openai/codex/releases/latest" + local target_asset="${SLOPTRAP_CODEX_ARCHIVE}.tar.gz" + [[ -n $SLOPTRAP_CODEX_ARCHIVE ]] || error "Codex archive name is not set" if ! command -v jq >/dev/null 2>&1; then error "jq is required to verify the Codex binary digest" fi @@ -698,7 +816,7 @@ fetch_latest_codex_digest() { error "failed to download Codex release metadata from GitHub" fi local digest_line - digest_line=$(jq -r '.assets[] | select(.name == "codex-x86_64-unknown-linux-gnu.tar.gz") | .digest' <<<"$response" | head -n 1) + digest_line=$(jq -r --arg name "$target_asset" '.assets[] | select(.name == $name) | .digest' <<<"$response" | head -n 1) if [[ -z $digest_line || $digest_line == "null" ]]; then error "failed to resolve Codex digest from GitHub response" fi @@ -711,14 +829,15 @@ ensure_codex_binary() { if [[ -x $CODEX_BIN_PATH ]]; then return 0 fi + local tar_transform="s/${SLOPTRAP_CODEX_ARCHIVE}/${SLOPTRAP_CODEX_BIN_NAME}/" local download_dir download_dir=$(create_temp_dir "codex") local tmp_archive="$download_dir/codex.tar.gz" if $DRY_RUN; then print_command curl -Lso "$tmp_archive" "$SLOPTRAP_CODEX_URL" print_command sha256sum -c - - print_command tar -xzf "$tmp_archive" --transform="s/codex-x86_64-unknown-linux-gnu/$SLOPTRAP_CODEX_BIN_NAME/" -C "$SLOPTRAP_BUILD_CONTEXT" - print_command chmod +x "$CODEX_BIN_PATH" + print_command tar -xzf "$tmp_archive" --transform="$tar_transform" -C "$SLOPTRAP_BUILD_CONTEXT" + print_command chmod 0755 "$CODEX_BIN_PATH" return 0 fi if ! curl -Lso "$tmp_archive" "$SLOPTRAP_CODEX_URL"; then @@ -731,12 +850,12 @@ ensure_codex_binary() { rm -rf "$download_dir" "$CODEX_BIN_PATH" error "checksum verification failed for $SLOPTRAP_CODEX_BIN_NAME" fi - if ! tar -xzf "$tmp_archive" --transform="s/codex-x86_64-unknown-linux-gnu/$SLOPTRAP_CODEX_BIN_NAME/" -C "$SLOPTRAP_BUILD_CONTEXT"; then + if ! tar -xzf "$tmp_archive" --transform="$tar_transform" -C "$SLOPTRAP_BUILD_CONTEXT"; then rm -rf "$download_dir" error "failed to extract Codex binary" fi rm -rf "$download_dir" - chmod +x "$CODEX_BIN_PATH" + chmod 0755 "$CODEX_BIN_PATH" } ensure_safe_sandbox() { @@ -776,7 +895,7 @@ normalize_package_list() { prepare_container_runtime() { resolve_container_workdir SLOPTRAP_PACKAGES_EXTRA_RESOLVED=$PACKAGES_EXTRA - SLOPTRAP_SHARED_DIR_ABS=$(get_env_default "SLOPTRAP_SHARED_DIR" "$CODE_DIR") + SLOPTRAP_SHARED_DIR_ABS="$CODE_DIR" if [[ ! -d $SLOPTRAP_SHARED_DIR_ABS ]]; then error "shared directory '$SLOPTRAP_SHARED_DIR_ABS' does not exist" fi @@ -799,7 +918,28 @@ prepare_container_runtime() { fi SLOPTRAP_PACKAGES_BASE=$(get_env_default "SLOPTRAP_PACKAGES" "curl bash ca-certificates libstdc++6 git ripgrep xxd file procps") - SLOPTRAP_CODEX_URL=$(get_env_default "SLOPTRAP_CODEX_URL" "https://github.com/openai/codex/releases/latest/download/codex-x86_64-unknown-linux-gnu.tar.gz") + validate_package_list "SLOPTRAP_PACKAGES" "$SLOPTRAP_PACKAGES_BASE" "SLOPTRAP_PACKAGES" + local default_codex_archive + default_codex_archive=$(detect_codex_archive_name) + local env_codex_archive + env_codex_archive=$(printenv "SLOPTRAP_CODEX_ARCHIVE" 2>/dev/null || true) + if [[ -n $env_codex_archive ]]; then + SLOPTRAP_CODEX_ARCHIVE=$env_codex_archive + else + SLOPTRAP_CODEX_ARCHIVE=$default_codex_archive + fi + validate_codex_archive_name "$SLOPTRAP_CODEX_ARCHIVE" + local default_codex_url="https://github.com/openai/codex/releases/latest/download/${SLOPTRAP_CODEX_ARCHIVE}.tar.gz" + SLOPTRAP_CODEX_URL=$(get_env_default "SLOPTRAP_CODEX_URL" "$default_codex_url") + if [[ -z $env_codex_archive ]]; then + local inferred_archive + inferred_archive=$(basename "${SLOPTRAP_CODEX_URL%%\?*}") + inferred_archive=${inferred_archive%.tar.gz} + if [[ $inferred_archive == codex-* ]]; then + validate_codex_archive_name "$inferred_archive" + SLOPTRAP_CODEX_ARCHIVE=$inferred_archive + fi + fi SLOPTRAP_CODEX_BIN_NAME=$(get_env_default "SLOPTRAP_CODEX_BIN" "codex") SLOPTRAP_CODEX_HOME_CONT=$(get_env_default "SLOPTRAP_CODEX_HOME_CONT" "/codex") SLOPTRAP_CODEX_UID=$(get_env_default "SLOPTRAP_CODEX_UID" "1337") @@ -828,6 +968,8 @@ prepare_container_runtime() { SLOPTRAP_ROOTFS_READONLY=$(get_env_default "SLOPTRAP_ROOTFS_READONLY" "1") SLOPTRAP_IMAGE_NAME=$(get_env_default "SLOPTRAP_IMAGE_NAME" "${PROJECT_NAME}-sloptrap-image") SLOPTRAP_CONTAINER_NAME=$(get_env_default "SLOPTRAP_CONTAINER_NAME" "${PROJECT_NAME}-sloptrap-container") + SLOPTRAP_IMAGE_NAME=$(sanitize_engine_name "$SLOPTRAP_IMAGE_NAME") + SLOPTRAP_CONTAINER_NAME=$(sanitize_engine_name "$SLOPTRAP_CONTAINER_NAME") local -a network_opts=(--network "$SLOPTRAP_NETWORK_NAME" --init) local -a security_opts=(--cap-drop=ALL --security-opt no-new-privileges) @@ -1167,6 +1309,9 @@ if [[ ! -d $CODE_DIR_INPUT ]]; then fi CODE_DIR="$(cd "$CODE_DIR_INPUT" && pwd -P)" +if [[ $CODE_DIR == "/" ]]; then + error "project root may not be '/'" +fi MANIFEST_PATH="$CODE_DIR/$MANIFEST_BASENAME" if [[ -f $MANIFEST_PATH ]]; then