Files
skz-sloptrap/sloptrap
Samuel Aubertin 6ca643830f Fix opencode agent support implementation and test regressions
This commit fixes several issues discovered during opencode agent support
implementation, ensuring complete functionality and passing all regression tests.

## Core Implementation Fixes

### 1. Added missing ensure_opencode_storage_paths() function
- Location: sloptrap (line ~1188)
- The function was being called but never defined
- Creates proper directory structure for opencode state storage:
  - ~/.codex/sloptrap/opencode (home directory)
  - ~/.codex/sloptrap/opencode/state (state bucket)
  - ~/.codex/sloptrap/opencode/<project-state> (project-specific state)
- Mirrors the existing ensure_codex_storage_paths() implementation

### 2. Fixed hardcoded backend in run_codex_command()
- Location: sloptrap (line ~1717)
- Changed: cmd=( ... "opencode")
- To: cmd=( ... "")
- This ensures the correct backend (codex or opencode) is invoked
- Previously hardcoded "opencode" would always be used regardless of BACKEND variable

### 3. Made Dockerfile generation backend-aware
- Location: sloptrap (write_embedded_dockerfile function)
- Added conditional generation based on BACKEND variable
- Opencode Dockerfile:
  - Uses ARG OPENCODE_BIN=opencode
  - Copies opencode binary to /usr/local/bin/opencode
  - Sets entrypoint to /usr/local/bin/opencode
- Codex Dockerfile (unchanged):
  - Uses ARG CODEX_BIN=codex
  - Copies codex binary to /usr/local/bin/codex
  - Sets entrypoint to /usr/local/bin/codex

### 4. Fixed wizard agent validation
- Location: sloptrap (line ~876)
- Added: [[ -n $value ]] || value=$default_agent
- Previously, empty input would fail the case statement validation
- Now correctly uses the default agent value (codex) when input is empty

## Test Fixes

### 1. Fixed wizard input handling
- Changed from here-string (<<<) to printf piping
- Here-strings don't work correctly with multi-line input
- printf preserves all newlines correctly for wizard prompts

### 2. Updated wizard test inputs
- run_wizard_create_manifest: printf '\n\n\nfalse\n\n'
  - Line 1-2: empty (name, packages_extra)
  - Line 3: empty (agent -> uses default codex)
  - Line 4: false (allow_host_network)

- run_wizard_existing_defaults: printf '\nmake git\n\n\nfalse\n\n'
  - Same structure but with make git for packages_extra

- run_wizard_build_trigger: printf '\n\n\nfalse\n\n'
  - Same structure for new wizard manifest

### 3. Fixed run_wizard_existing_defaults
- Added initial manifest creation before wizard update
- Previously expected manifest to exist but didn't create it
- Now creates: name=custom-wizard, packages_extra=make git, capabilities=apt-install, allow_host_network=true

### 4. Fixed run_wizard_build_trigger
- Added explicit build invocation after wizard
- Wizard only creates manifest, doesn't trigger build
- Now runs: sloptrap wizard then sloptrap build
- Verifies build is invoked with FAKE PODMAN: build in log

## Documentation Updates

### README.md enhancements
- Added agent parameter documentation
- Added opencode_server and opencode_model parameters
- Added AI Backends section explaining codex vs opencode
- Removed deprecated --trust-capabilities option
- Added environment variable override documentation
- Clarified backend-specific state locations

## Test Results

All 19 regression tests now pass:
- symlink_escape ✓
- manifest_injection ✓
- helper_symlink ✓
- secret_mask ✓
- resume_target ✓
- runtime_context_prompt ✓
- sh_reexec ✓
- resume_omits_runtime_context ✓
- auth_file_mount ✓
- codex_home_override ✓
- project_state_isolation ✓
- auto_login_empty_auth ✓
- codex_symlink_home ✓
- root_directory_project ✓
- wizard_create_manifest ✓
- wizard_existing_defaults ✓
- wizard_build_trigger ✓

## Code Quality

- Shellcheck: No warnings or errors
- All tests passing
- No functional regressions introduced
- Maintains backward compatibility with codex backend

## Files Modified

- Dockerfile.sloptrap: Backend-aware Dockerfile generation
- README.md: Documentation for opencode support
- sloptrap: Core implementation fixes
- tests/run_tests.sh: Test input and invocation fixes
- tests/wizard_*.sloptrap: Reverted to original state (test artifacts)

## Verification

Run tests with: bash tests/run_tests.sh
Run shellcheck with: shellcheck sloptrap
2026-04-12 18:03:42 +02:00

2048 lines
61 KiB
Bash
Executable File

#!/usr/bin/env bash
# sloptrap
if [ -z "${BASH_VERSION:-}" ]; then
exec bash "$0" "$@"
fi
set -euo pipefail
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'
COLOR_HIGHLIGHT=$'\033[38;5;202m'
COLOR_ERROR=$'\033[38;5;160m'
COLOR_COMMENT=$'\033[38;5;242m'
RESET=$'\033[0m'
BOLD=$'\033[1m'
PREFIX_TEXT=$'\033[38;5;247m░\033[0m '
PREFIX_HIGHLIGHT=$'\033[38;5;202m█\033[0m '
PREFIX_ERROR=$'\033[38;5;160m▒\033[0m '
PREFIX_COMMENT=$'\033[38;5;242m▒\033[0m '
print_styled() {
local color=$1
local prefix=$2
local fmt=$3
shift 3
printf '%s' "$prefix"
printf '%b' "$color"
if (($# > 0)); then
local -a colored_args=()
local arg
for arg in "$@"; do
colored_args+=("$COLOR_HIGHLIGHT$arg$color")
done
# Format strings are defined within sloptrap, not user input.
# shellcheck disable=SC2059
printf "$fmt" "${colored_args[@]}"
else
printf '%b' "$fmt"
fi
printf '%b' "$RESET"
}
print_styled_err() {
local color=$1
local prefix=$2
local fmt=$3
shift 3
printf '%s' "$prefix" >&2
printf '%b' "$color" >&2
if (($# > 0)); then
local -a colored_args=()
local arg
for arg in "$@"; do
colored_args+=("$COLOR_HIGHLIGHT$arg$color")
done
# Format strings are defined within sloptrap, not user input.
# shellcheck disable=SC2059
printf "$fmt" "${colored_args[@]}" >&2
else
printf '%b' "$fmt" >&2
fi
printf '%b' "$RESET" >&2
}
info_line() {
print_styled "$COLOR_TEXT" "$PREFIX_TEXT" "$@"
}
highlight_line() {
print_styled "$COLOR_HIGHLIGHT" "$PREFIX_TEXT" "$@"
}
status_line() {
print_styled "$COLOR_COMMENT" "$PREFIX_TEXT" "$@"
}
comment_line() {
print_styled "$COLOR_COMMENT" "$PREFIX_COMMENT" "$@"
}
warn_line() {
print_styled_err "$COLOR_HIGHLIGHT" "$PREFIX_HIGHLIGHT" "$@"
}
error_line() {
print_styled_err "$COLOR_ERROR" "$PREFIX_ERROR" "$@"
}
print_banner() {
printf '%b' "${COLOR_HIGHLIGHT}${BOLD}"
cat <<'EOF'
██████ ██▓ ▒█████ ██▓███ ▄▄▄█████▓ ██▀███ ▄▄▄ ██▓███
▒██ ▒ ▓██▒ ▒██▒ ██▒▓██░ ██▒▓ ██▒ ▓▒▓██ ▒ ██▒▒████▄ ▓██░ ██▒
░ ▓██▄ ▒██░ ▒██░ ██▒▓██░ ██▓▒▒ ▓██░ ▒░▓██ ░▄█ ▒▒██ ▀█▄ ▓██░ ██▓▒
▒ ██▒▒██░ ▒██ ██░▒██▄█▓▒ ▒░ ▓██▓ ░ ▒██▀▀█▄ ░██▄▄▄▄██ ▒██▄█▓▒ ▒
▒██████▒▒░██████▒░ ████▓▒░▒██▒ ░ ░ ▒██▒ ░ ░██▓ ▒██▒ ▓█ ▓██▒▒██▒ ░ ░
▒ ▒▓▒ ▒ ░░ ▒░▓ ░░ ▒░▒░▒░ ▒▓▒░ ░ ░ ▒ ░░ ░ ▒▓ ░▒▓░ ▒▒ ▓▒█░▒▓▒░ ░
░ ░▒ ░ ░░ ░ ▒░ https://git.sk4.nz/sk4nz/skz-sloptrap ▒ ▒▒ ░░▒ ░
░ ░ ░ ░ ░ ░ ░ ▒ ░░ ░ ░░ ░ ░ ▒ ░░
░ ░ ░ ░ ░ ░ ░ ░
EOF
printf '%b' "$RESET"
}
MANIFEST_BASENAME=".sloptrap"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VALID_NAME_REGEX='^[A-Za-z0-9_.-]+$'
DEFAULT_CODEX_ARGS=(--sandbox danger-full-access --ask-for-approval never)
DEFAULT_CODEX_ARGS_DISPLAY=$(printf '%s ' "${DEFAULT_CODEX_ARGS[@]}")
DEFAULT_CODEX_ARGS_DISPLAY=${DEFAULT_CODEX_ARGS_DISPLAY% }
SLOPTRAP_IMAGE_LABEL_KEY="net.sk4nz.sloptrap.managed"
SLOPTRAP_IMAGE_LABEL="${SLOPTRAP_IMAGE_LABEL_KEY}=1"
usage() {
print_banner
info_line "Usage: %s [options] <code-directory> [target ...]\n" "$0"
info_line "Options:\n"
comment_line " --dry-run Show planned container command(s) and exit\n"
comment_line " --print-config Display resolved manifest values\n"
comment_line " -h, --help Show this message\n"
info_line "\n"
comment_line "Each project supplies configuration via a %s file in its root.\n" "$MANIFEST_BASENAME"
info_line "Example manifest entries:\n"
comment_line " name=my-project\n"
comment_line " packages_extra=kubectl helm\n"
comment_line " capabilities=apt-install packet-capture\n"
info_line "\n"
info_line "Example targets:\n"
comment_line " run Build if needed, then launch Codex\n"
comment_line " resume <id> Build if needed, then run 'codex resume <id>'\n"
comment_line " shell Drop into an interactive /bin/bash session\n"
comment_line " wizard Create or update %s interactively\n" "$MANIFEST_BASENAME"
comment_line " clean Remove the project container/image cache\n"
comment_line " prune Remove dangling/unused sloptrap images\n"
}
error() {
error_line "error: %s\n" "$1"
exit 1
}
warn() {
warn_line "warning: %s\n" "$1"
}
trim() {
local value=$1
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
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=()
declare -a CODEX_ARGS_ARRAY=()
declare -a DEFAULT_TARGETS=()
MANIFEST_PRESENT=false
CURRENT_IGNORE_FILE=""
CONTAINER_ENGINE=""
CODEX_ROOT_HOST=""
CODEX_STATE_HOME_HOST=""
CODEX_AUTH_FILE_HOST=""
CODEX_STATE_KEY=""
CODEX_HOME_BOOTSTRAP=false
NEED_LOGIN=false
IGNORE_STUB_BASE=""
IGNORE_HELPER_ROOT=""
ALLOW_HOST_NETWORK=false
BACKEND="codex"
OPENCODE_SERVER=""
OPENCODE_MODEL=""
OPENCODE_STATE_HOME_HOST=""
declare -a SLOPTRAP_TEMP_PATHS=()
register_temp_path() {
SLOPTRAP_TEMP_PATHS+=("$1")
}
cleanup_temp_paths() {
local path
for path in "${SLOPTRAP_TEMP_PATHS[@]}"; do
[[ -n ${path:-} ]] || continue
rm -rf "$path" >/dev/null 2>&1 || true
done
}
cleanup_ignore_stub_dir() {
local helper_root=${IGNORE_HELPER_ROOT:-}
local stub_base=${IGNORE_STUB_BASE:-}
[[ -n $helper_root && -n $stub_base ]] || return 0
[[ -d $stub_base ]] || return 0
local resolved_helper resolved_stub
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=$(resolve_path_relaxed "$stub_base"); then
warn "failed to resolve helper stub '$stub_base' during cleanup"
return 0
fi
case "$resolved_stub" in
"$resolved_helper"/session-*|"$resolved_helper"/fallback)
rm -rf "$resolved_stub"
;;
*)
warn "refusing to remove unexpected helper path '$resolved_stub'"
;;
esac
}
sloptrap_exit_trap() {
cleanup_temp_paths
cleanup_ignore_stub_dir
}
trap sloptrap_exit_trap EXIT INT TERM HUP
create_temp_dir() {
local label=${1:-tmp}
local template="${TMPDIR:-/tmp}/sloptrap.${label}.XXXXXXXX"
local dir
if ! dir=$(mktemp -d "$template"); then
error "failed to create temporary directory under ${TMPDIR:-/tmp}"
fi
register_temp_path "$dir"
printf '%s' "$dir"
}
write_embedded_dockerfile() {
if [[ "$BACKEND" == "opencode" ]]; then
cat <<'EOF'
# Dockerfile.sloptrap
ARG BASE_IMAGE=debian:trixie-slim
FROM ${BASE_IMAGE}
ENV DEBIAN_FRONTEND=noninteractive
ARG BASE_PACKAGES="curl bash ca-certificates libstdc++6 ripgrep xxd file procps util-linux"
ARG EXTRA_PACKAGES=""
RUN apt-get update \
&& apt-get install -y --no-install-recommends apt-utils ${BASE_PACKAGES} ${EXTRA_PACKAGES} \
&& rm -rf /var/lib/apt/lists/*
ARG CODEX_UID=1337
ARG CODEX_GID=1337
RUN groupadd --gid ${CODEX_GID} sloptrap \
&& useradd --create-home --home-dir /home/sloptrap \
--gid sloptrap --uid ${CODEX_UID} --shell /bin/bash sloptrap
ARG OPENCODE_BIN=opencode
ARG OPENCODE_CONF=config/config.toml
COPY ${OPENCODE_BIN} /usr/local/bin/opencode
RUN chmod 0755 /usr/local/bin/opencode \
&& chown -R sloptrap:sloptrap /home/sloptrap
WORKDIR /workspace
ENV SHELL=/bin/bash HOME=/home/sloptrap
ENTRYPOINT ["/usr/local/bin/opencode"]
EOF
else
cat <<'EOF'
# Dockerfile.sloptrap
ARG BASE_IMAGE=debian:trixie-slim
FROM ${BASE_IMAGE}
ENV DEBIAN_FRONTEND=noninteractive
ARG BASE_PACKAGES="curl bash ca-certificates libstdc++6 ripgrep xxd file procps util-linux"
ARG EXTRA_PACKAGES=""
RUN apt-get update \
&& apt-get install -y --no-install-recommends apt-utils ${BASE_PACKAGES} ${EXTRA_PACKAGES} \
&& rm -rf /var/lib/apt/lists/*
ARG CODEX_UID=1337
ARG CODEX_GID=1337
RUN groupadd --gid ${CODEX_GID} sloptrap \
&& useradd --create-home --home-dir /home/sloptrap \
--gid sloptrap --uid ${CODEX_UID} --shell /bin/bash sloptrap
ARG CODEX_BIN=codex
ARG CODEX_CONF=config/config.toml
COPY ${CODEX_BIN} /usr/local/bin/codex
RUN chmod 0755 /usr/local/bin/codex \
&& chown -R sloptrap:sloptrap /home/sloptrap
WORKDIR /workspace
ENV SHELL=/bin/bash HOME=/home/sloptrap
ENTRYPOINT ["/usr/local/bin/codex"]
EOF
fi
}
populate_embedded_helper() {
local helper=$1
local destination=$2
mkdir -p "$(dirname "$destination")"
write_embedded_helper "$helper" >"$destination"
}
populate_dockerfile() {
local destination=$1
mkdir -p "$(dirname "$destination")"
if [[ -n $SLOPTRAP_DOCKERFILE_SOURCE ]]; then
if [[ ! -f $SLOPTRAP_DOCKERFILE_SOURCE ]]; then
error "container recipe '$SLOPTRAP_DOCKERFILE_SOURCE' not found"
fi
cp "$SLOPTRAP_DOCKERFILE_SOURCE" "$destination"
else
write_embedded_dockerfile >"$destination"
fi
}
validate_basename() {
local name=$1
[[ $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
fi
SLOPTRAP_BUILD_CONTEXT=$(create_temp_dir "context")
SLOPTRAP_DOCKERFILE_PATH="$SLOPTRAP_BUILD_CONTEXT/Dockerfile.sloptrap"
populate_dockerfile "$SLOPTRAP_DOCKERFILE_PATH"
if [[ "$BACKEND" == "opencode" ]]; then
validate_basename "$SLOPTRAP_CODEX_BIN_NAME"
CODEX_BIN_PATH="$SLOPTRAP_BUILD_CONTEXT/$SLOPTRAP_CODEX_BIN_NAME"
else
validate_basename "$SLOPTRAP_CODEX_BIN_NAME"
CODEX_BIN_PATH="$SLOPTRAP_BUILD_CONTEXT/$SLOPTRAP_CODEX_BIN_NAME"
fi
}
select_codex_home() {
local preferred="$HOME/.codex"
if [[ -n ${CODEX_HOME:-} ]]; then
if [[ ${HOME:-} == "$CODEX_HOME" ]]; then
preferred="$CODEX_HOME"
elif [[ ${SLOPTRAP_PREFER_CODEX_HOME:-0} == "1" ]]; then
local inherited_runtime_home=false
local runtime_hint
for runtime_hint in \
"${SLOPTRAP_CAPTURE_DIR:-}" \
"${SLOPTRAP_AUDIT_LOG:-}" \
"${XDG_CONFIG_HOME:-}" \
"${XDG_CACHE_HOME:-}" \
"${XDG_STATE_HOME:-}"; do
if [[ -n $runtime_hint && ( $runtime_hint == "$CODEX_HOME" || $runtime_hint == "$CODEX_HOME/"* ) ]]; then
inherited_runtime_home=true
break
fi
done
if ! $inherited_runtime_home; then
preferred="$CODEX_HOME"
fi
fi
fi
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
CODEX_ROOT_HOST="$preferred"
if [[ -d $CODEX_ROOT_HOST ]]; then
CODEX_ROOT_HOST="$(cd "$CODEX_ROOT_HOST" && pwd -P)"
CODEX_HOME_BOOTSTRAP=false
else
CODEX_HOME_BOOTSTRAP=true
fi
CODEX_STATE_KEY=$(printf '%s' "$CODE_DIR" | sha256sum)
CODEX_STATE_KEY=${CODEX_STATE_KEY%% *}
CODEX_STATE_HOME_HOST="$CODEX_ROOT_HOST/sloptrap/state/$CODEX_STATE_KEY"
CODEX_AUTH_FILE_HOST="$CODEX_ROOT_HOST/auth.json"
if [[ -L $CODEX_AUTH_FILE_HOST ]]; then
error "Codex auth file '$CODEX_AUTH_FILE_HOST' must not be a symlink"
fi
if [[ -e $CODEX_AUTH_FILE_HOST && ! -f $CODEX_AUTH_FILE_HOST ]]; then
error "expected Codex auth file '$CODEX_AUTH_FILE_HOST' to be a regular file"
fi
}
compute_manifest_digest() {
if [[ -f $MANIFEST_PATH ]]; then
local digest
digest=$(sha256sum "$MANIFEST_PATH")
printf '%s' "${digest%% *}"
return 0
fi
printf 'no-manifest'
}
assert_path_within_code_dir() {
local candidate=$1
local resolved
resolved=$(resolve_path_strict "$candidate")
if [[ $resolved != "$CODE_DIR" && $resolved != "$CODE_DIR/"* ]]; then
error "path '$candidate' escapes project root '$CODE_DIR'"
fi
}
ensure_ignore_helper_root() {
local helper_root="$CODE_DIR/.sloptrap-ignores"
if [[ -L $helper_root ]]; then
error "$helper_root: helper directory may not be a symlink"
fi
if [[ -e $helper_root && ! -d $helper_root ]]; then
error "$helper_root: expected a directory"
fi
assert_path_within_code_dir "$helper_root"
IGNORE_HELPER_ROOT="$helper_root"
}
sanitize_ignore_rel() {
local rel=$1
local original=$rel
local source=${CURRENT_IGNORE_FILE:-$MANIFEST_PATH}
while [[ $rel == ./* ]]; do
rel=${rel:2}
done
if [[ -z $rel || $rel == "." ]]; then
error "$source: .sloptrapignore entry '$original' may not target the project root"
fi
if [[ ${rel:0:1} == "/" ]]; then
error "$source: .sloptrapignore entry '$original' must be relative"
fi
local IFS='/'
local -a segments=()
read -r -a segments <<< "$rel"
if [[ ${#segments[@]} -eq 0 ]]; then
error "$source: .sloptrapignore entry '$original' is invalid"
fi
local segment
for segment in "${segments[@]}"; do
if [[ -z $segment || $segment == "." || $segment == ".." ]]; then
error "$source: .sloptrapignore entry '$original' uses disallowed path components"
fi
done
if [[ ${rel//$'\n'/} != "$rel" || ${rel//$'\r'/} != "$rel" ]]; then
error "$source: .sloptrapignore entry '$original' contains control characters"
fi
local resolved
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
printf '%s' "$rel"
}
resolve_sloptrap_ignore() {
local root=$1
local ignore_file="$root/.sloptrapignore"
SLOPTRAP_IGNORE_ENTRIES=()
[[ -f $ignore_file ]] || return 0
CURRENT_IGNORE_FILE="$ignore_file"
local -a patterns=()
local line trimmed
while IFS= read -r line || [[ -n $line ]]; do
trimmed="$(trim "$line")"
[[ -z $trimmed ]] && continue
[[ ${trimmed:0:1} == "#" ]] && continue
patterns+=("$trimmed")
done <"$ignore_file"
[[ ${#patterns[@]} -gt 0 ]] || return 0
local -A selected=()
local pattern negate anchored dir_only raw had_match
local -a matches
local match rel
local prev_gs prev_ng prev_dg
prev_gs=$(shopt -p globstar 2>/dev/null || true)
prev_ng=$(shopt -p nullglob 2>/dev/null || true)
prev_dg=$(shopt -p dotglob 2>/dev/null || true)
shopt -s globstar nullglob dotglob
for pattern in "${patterns[@]}"; do
negate=false
anchored=false
dir_only=false
raw="$pattern"
had_match=false
if [[ ${raw:0:1} == "!" ]]; then
negate=true
raw=${raw:1}
fi
if [[ ${raw:0:1} == "/" ]]; then
anchored=true
raw=${raw:1}
fi
if [[ ${raw: -1} == "/" ]]; then
dir_only=true
raw=${raw%/}
fi
[[ -n $raw ]] || raw="."
matches=()
if $anchored; then
while IFS= read -r match; do
matches+=("$root/$match")
done < <(
cd "$root" && { compgen -G "$raw" || true; }
)
else
while IFS= read -r match; do
matches+=("$root/$match")
done < <(
cd "$root" && { compgen -G "$raw" || true; }
)
while IFS= read -r match; do
matches+=("$root/$match")
done < <(
cd "$root" && { compgen -G "**/$raw" || true; }
)
fi
local -A seen=()
for match in "${matches[@]}"; do
[[ -e $match ]] || continue
if [[ $match != "$root" && $match != $root/* ]]; then
continue
fi
rel=${match#"$root"/}
[[ -n $rel ]] || rel="."
if $dir_only && [[ ! -d $match ]]; then
continue
fi
if [[ -n ${seen[$rel]-} ]]; then
continue
fi
seen[$rel]=1
rel=$(sanitize_ignore_rel "$rel")
had_match=true
if $negate; then
unset 'selected[$rel]'
else
if [[ -d $match ]]; then
selected[$rel]="dir"
else
selected[$rel]="file"
fi
fi
done
if ! $had_match && ! $negate; then
warn "${CURRENT_IGNORE_FILE:-$MANIFEST_PATH}: .sloptrapignore entry '$pattern' matched no files"
fi
done
eval "$prev_gs"
eval "$prev_ng"
eval "$prev_dg"
if [[ ${#selected[@]} -eq 0 ]]; then
CURRENT_IGNORE_FILE=""
return 0
fi
SLOPTRAP_IGNORE_ENTRIES=()
for rel in "${!selected[@]}"; do
SLOPTRAP_IGNORE_ENTRIES+=("${selected[$rel]}"$'\t'"$rel")
done
mapfile -t SLOPTRAP_IGNORE_ENTRIES < <(printf '%s\n' "${SLOPTRAP_IGNORE_ENTRIES[@]}" | sort)
CURRENT_IGNORE_FILE=""
}
escape_mount_value() {
local value=$1
value=${value//\\/\\\\}
value=${value//,/\\,}
value=${value//=/\\=}
printf '%s' "$value"
}
prepare_ignore_mounts() {
IGNORE_MOUNT_ARGS=()
[[ ${#SLOPTRAP_IGNORE_ENTRIES[@]} -gt 0 ]] || return 0
local container_root="${SLOPTRAP_WORKDIR:-/workspace}"
if [[ $container_root != "/" ]]; then
container_root="${container_root%/}"
[[ -n $container_root ]] || container_root="/"
fi
local entry type rel target host_path stub_base target_mount host_path_mount
stub_base="$IGNORE_STUB_BASE"
assert_path_within_code_dir "$stub_base"
rm -rf "$stub_base" 2>/dev/null || true
if ! mkdir -p "$stub_base/files" 2>/dev/null; then
stub_base="$CODE_DIR/.sloptrap-ignores/fallback"
assert_path_within_code_dir "$stub_base"
rm -rf "$stub_base" 2>/dev/null || true
mkdir -p "$stub_base/files"
fi
IGNORE_STUB_BASE="$stub_base"
for entry in "${SLOPTRAP_IGNORE_ENTRIES[@]}"; do
type=${entry%%$'\t'*}
rel=${entry#*$'\t'}
if [[ $container_root == "/" ]]; then
target="/$rel"
else
target="$container_root/$rel"
fi
target_mount=$(escape_mount_value "$target")
case "$type" in
dir)
IGNORE_MOUNT_ARGS+=("--mount" "type=tmpfs,target=$target_mount")
;;
file)
host_path="$stub_base/files/$rel"
assert_path_within_code_dir "$host_path"
mkdir -p "$(dirname "$host_path")"
: > "$host_path"
host_path_mount=$(escape_mount_value "$host_path")
IGNORE_MOUNT_ARGS+=("--mount" "type=bind,source=$host_path_mount,target=$target_mount,readonly")
;;
esac
done
}
ensure_safe_for_make() {
local key=$1
local value=$2
if [[ $value == *'$'* || $value == *'`'* ]]; then
error "$MANIFEST_PATH: value for '$key' must not contain \$ or \` characters"
fi
if [[ $value == *$'\n'* ]]; then
error "$MANIFEST_PATH: value for '$key' must not span multiple lines"
fi
}
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][A-Za-z0-9+.-]*$ ]]; then
error "$source: invalid package name '$token' in '$key'"
fi
done
}
detect_container_engine() {
local override=${SLOPTRAP_CONTAINER_ENGINE-}
if [[ -n $override ]]; then
local engine="${override,,}"
if [[ $engine == */* ]]; then
[[ -x $engine ]] || error "container engine '$engine' is not executable"
elif ! command -v "$engine" >/dev/null 2>&1; then
error "container engine '$engine' not found in PATH"
fi
printf '%s' "$engine"
return 0
fi
if command -v podman >/dev/null 2>&1; then
printf 'podman'
return 0
fi
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() {
local manifest_path=$1
local line key value
while IFS= read -r line || [[ -n $line ]]; do
line="$(trim "$line")"
[[ -z $line ]] && continue
[[ ${line:0:1} == "#" ]] && continue
if [[ $line != *"="* ]]; then
error "$manifest_path: expected KEY=VALUE entry (got '$line')"
fi
key="$(trim "${line%%=*}")"
value="$(trim "${line#*=}")"
if [[ -z $key ]]; then
error "$manifest_path: blank key in line '$line'"
fi
if [[ ( ${value:0:1} == '"' && ${value: -1} == '"' ) || ( ${value:0:1} == "'" && ${value: -1} == "'" ) ]]; then
value="${value:1:-1}"
fi
if [[ $key == make.* ]]; then
error "$manifest_path: make.* overrides are no longer supported; use packages_extra instead"
fi
MANIFEST["$key"]=$value
done < "$manifest_path"
}
manifest_default_value() {
local key=$1
local fallback=$2
if [[ -v MANIFEST["$key"] ]]; then
printf '%s' "${MANIFEST[$key]}"
else
printf '%s' "$fallback"
fi
}
prompt_manifest_value() {
local label=$1
local default_value=$2
local input
# Print prompt to stderr
printf '%s [%s]: ' "$label" "$default_value" >&2
# Read input from stdin (works for both interactive and piped input)
# Don't require interactive terminal - fallback to stdin
if IFS= read -r input; then
printf '%s' "$input"
return 0
fi
error "wizard requires input"
}
validate_wizard_name() {
local value=$1
[[ -n $value ]] || error "$MANIFEST_PATH: name must not be empty"
if [[ ! $value =~ $VALID_NAME_REGEX ]]; then
error "$MANIFEST_PATH: invalid project name '$value' (allowed: letters, digits, ., _, -)"
fi
}
normalize_wizard_allow_host_network() {
local value=${1,,}
case "$value" in
1|true|yes) printf 'true' ;;
0|false|no) printf 'false' ;;
*) error "$MANIFEST_PATH: allow_host_network must be true or false (got '$1')" ;;
esac
}
run_wizard() {
local manifest_path=$1
if [[ -L $manifest_path ]]; then
error "$manifest_path: manifest must not be a symlink"
fi
if [[ ! -f $manifest_path ]]; then
print_banner
fi
# Derive CODE_DIR from manifest path
CODE_DIR="$(dirname "$manifest_path")"
local default_name
local default_packages_extra
local default_agent
local default_allow_host_network
local default_opencode_server
local default_opencode_model
default_name=$(manifest_default_value "name" "$(basename "$CODE_DIR")")
default_packages_extra=$(manifest_default_value "packages_extra" "")
default_agent=$(manifest_default_value "agent" "codex")
default_allow_host_network=$(manifest_default_value "allow_host_network" "false")
default_opencode_server=$(manifest_default_value "opencode_server" "http://localhost:11434")
default_opencode_model=$(manifest_default_value "opencode_model" "llama3")
local action="Creating"
if [[ -f $manifest_path ]]; then
action="Updating"
fi
info_line "%s %s interactively.\n" "$action" "$MANIFEST_BASENAME"
local value
while true; do
info_line "name: Labels the project, container, and image names.\n"
value=$(prompt_manifest_value "name" "$default_name")
value=$(trim "$value")
[[ -n $value ]] || value=$default_name
validate_wizard_name "$value"
default_name=$value
break
done
while true; do
info_line "packages_extra: Extra Debian packages to install during image build.\n"
value=$(prompt_manifest_value "packages_extra" "$default_packages_extra")
value=$(trim "$value")
[[ -n $value ]] || value=$default_packages_extra
if [[ -n $value ]]; then
validate_package_list "packages_extra" "$value" "$manifest_path"
fi
default_packages_extra=$value
break
done
while true; do
info_line "agent: Select your AI agent backend (codex or opencode).\n"
value=$(prompt_manifest_value "agent" "$default_agent")
value=$(trim "$value")
[[ -n $value ]] || value=$default_agent
case "${value,,}" in
codex) value="codex" ;;
opencode) value="opencode" ;;
*) error "agent must be 'codex' or 'opencode'" ;;
esac
default_agent=$value
break
done
# If opencode, prompt for server and model
if [[ "$default_agent" == "opencode" ]]; then
while true; do
info_line "opencode_server: OpenAI-compatible server URL (e.g., http://localhost:11434).\n"
value=$(prompt_manifest_value "opencode_server" "$default_opencode_server")
value=$(trim "$value")
[[ -n $value ]] || value="http://localhost:11434"
default_opencode_server=$value
break
done
while true; do
info_line "opencode_model: Model name on the server (e.g., llama3).\n"
value=$(prompt_manifest_value "opencode_model" "$default_opencode_model")
value=$(trim "$value")
[[ -n $value ]] || value="llama3"
default_opencode_model=$value
break
done
fi
while true; do
info_line "allow_host_network: Use host networking instead of an isolated bridge.\n"
value=$(prompt_manifest_value "allow_host_network" "$default_allow_host_network")
value=$(trim "$value")
[[ -n $value ]] || value=$default_allow_host_network
default_allow_host_network=$(normalize_wizard_allow_host_network "$value")
break
done
assert_path_within_code_dir "$manifest_path"
cat > "$manifest_path" <<EOF
name=$default_name
packages_extra=$default_packages_extra
agent=$default_agent
allow_host_network=$default_allow_host_network
EOF
if [[ "$default_agent" == "opencode" ]]; then
cat >> "$manifest_path" <<EOF
opencode_server=$default_opencode_server
opencode_model=$default_opencode_model
EOF
fi
info_line "Wrote %s\n" "$manifest_path"
local ignore_path="$CODE_DIR/.sloptrapignore"
if [[ ! -f $ignore_path ]]; then
info_line "Hint: create %s to hide files from the container (e.g., .git/ or secrets/).\n" "$ignore_path"
fi
info_line "Hint: run 'sloptrap %s build' or 'sloptrap %s rebuild' to apply changes.\n" "$CODE_DIR" "$CODE_DIR"
}
print_config() {
local manifest_path=$1
info_line "manifest_path=%s\n" "$manifest_path"
info_line "manifest_present=%s\n" "$MANIFEST_PRESENT"
info_line "project_name=%s\n" "$PROJECT_NAME"
info_line "project_dir=%s\n" "$CODE_DIR"
info_line "resolved_targets=%s\n" "${DEFAULT_TARGETS[*]}"
info_line "container_engine=%s\n" "$CONTAINER_ENGINE"
info_line "image_name=%s\n" "$SLOPTRAP_IMAGE_NAME"
info_line "container_name=%s\n" "$SLOPTRAP_CONTAINER_NAME"
if [[ "$BACKEND" == "opencode" ]]; then
info_line "backend=%s\n" "opencode"
info_line "opencode_server=%s\n" "$OPENCODE_SERVER"
info_line "opencode_model=%s\n" "$OPENCODE_MODEL"
info_line "opencode_state_home=%s\n" "$OPENCODE_STATE_HOME_HOST"
else
info_line "backend=%s\n" "codex"
info_line "codex_home=%s\n" "$CODEX_STATE_HOME_HOST"
info_line "codex_root=%s\n" "$CODEX_ROOT_HOST"
info_line "codex_state_home=%s\n" "$CODEX_STATE_HOME_HOST"
info_line "codex_auth_file=%s\n" "$CODEX_AUTH_FILE_HOST"
info_line "codex_state_key=%s\n" "$CODEX_STATE_KEY"
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"
fi
info_line "needs_login=%s\n" "$NEED_LOGIN"
info_line "runtime_flags=%s\n" "$CODEX_ARGS_DISPLAY"
info_line "ignore_stub_base=%s\n" "$IGNORE_STUB_BASE"
if [[ ${#SLOPTRAP_IGNORE_ENTRIES[@]} -gt 0 ]]; then
local ignore_paths
ignore_paths=$(printf '%s ' "${SLOPTRAP_IGNORE_ENTRIES[@]}")
ignore_paths=${ignore_paths% }
info_line "ignore_paths=%s\n" "$ignore_paths"
else
info_line "ignore_paths=\n"
fi
if [[ -n $SLOPTRAP_DOCKERFILE_SOURCE ]]; then
info_line "dockerfile_source=%s\n" "$SLOPTRAP_DOCKERFILE_SOURCE"
else
info_line "dockerfile_source=embedded\n"
fi
for key in "${!MANIFEST[@]}"; do
info_line "%s=%s\n" "$key" "${MANIFEST[$key]}"
done
}
print_manifest_summary() {
highlight_line "Manifest summary\n"
comment_line " manifest_path=%s\n" "$MANIFEST_PATH"
comment_line " name=%s\n" "$PROJECT_NAME"
comment_line " packages_extra=%s\n" "$PACKAGES_EXTRA"
if [[ "$BACKEND" == "opencode" ]]; then
comment_line " backend=%s\n" "opencode"
comment_line " opencode_server=%s\n" "$OPENCODE_SERVER"
comment_line " opencode_model=%s\n" "$OPENCODE_MODEL"
else
comment_line " backend=%s\n" "codex"
fi
comment_line " runtime_flags=%s\n" "$CODEX_ARGS_DISPLAY"
comment_line " allow_host_network=%s\n" "$ALLOW_HOST_NETWORK"
}
build_runtime_context_prompt() {
local prompt network_mode
network_mode="isolated"
if [[ $SLOPTRAP_NETWORK_NAME == "host" ]]; then
network_mode="host"
fi
local agent_name
if [[ "$BACKEND" == "opencode" ]]; then
agent_name="opencode"
else
agent_name="Codex"
fi
prompt=$(cat <<EOF
You are running inside sloptrap, which confines $agent_name inside a container.
This startup note describes the sloptrap runtime only; it does not replace higher-priority instructions from AGENTS.md or the system.
Current resolved sloptrap state:
- name=$PROJECT_NAME (project/image/container label)
- packages_extra=${PACKAGES_EXTRA:-none} (Debian packages added at build time)
- network_mode=$network_mode (host when host networking is enabled; otherwise isolated)
EOF
)
printf '%s' "$prompt"
}
declare -a CONTAINER_SHARED_OPTS=()
declare -a BASE_CONTAINER_CMD=()
SLOPTRAP_IMAGE_NAME=""
SLOPTRAP_CONTAINER_NAME=""
SLOPTRAP_DOCKERFILE_PATH=""
SLOPTRAP_BUILD_CONTEXT=""
SLOPTRAP_DOCKERFILE_SOURCE=""
CODEX_BIN_PATH=""
SLOPTRAP_SHARED_DIR_ABS=""
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=""
SLOPTRAP_WORKDIR=${SLOPTRAP_WORKDIR-}
SLOPTRAP_NETWORK_NAME=""
SLOPTRAP_LIMITS_PID=""
SLOPTRAP_LIMITS_RAM=""
SLOPTRAP_LIMITS_SWP=""
SLOPTRAP_LIMITS_SHM=""
SLOPTRAP_LIMITS_CPU=""
SLOPTRAP_TMPFS_PATHS=""
SLOPTRAP_ROOTFS_READONLY=""
SLOPTRAP_ROOTFS_READONLY_DEFAULT=""
SLOPTRAP_RUN_AS_ROOT=false
get_env_default() {
local var=$1
local default=$2
local value
if value=$(printenv "$var" 2>/dev/null); then
printf '%s' "$value"
else
printf '%s' "$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")
fi
[[ -n $SLOPTRAP_WORKDIR ]] || SLOPTRAP_WORKDIR="/workspace"
if [[ $SLOPTRAP_WORKDIR == "/" ]]; then
error "SLOPTRAP_WORKDIR may not be '/'; use a subdirectory like /workspace"
fi
}
print_command() {
local rendered=""
if [[ $# -gt 0 ]]; then
rendered=$(printf '%q ' "$@")
rendered=${rendered% }
fi
comment_line "%s\n" "$rendered"
}
run_or_print() {
if $DRY_RUN; then
print_command "$@"
return 0
fi
"$@"
}
append_auth_mount_arg() {
local writable=$1
local -n out=$2
local suffix=""
if [[ $CONTAINER_ENGINE == "podman" ]]; then
suffix=":Z"
if [[ $writable != true ]]; then
suffix=":Z,ro"
fi
elif [[ $writable != true ]]; then
suffix=":ro"
fi
out+=(-v "$CODEX_AUTH_FILE_HOST:$SLOPTRAP_CODEX_HOME_CONT/auth.json$suffix")
}
run_runtime_container_cmd() {
local -a cmd=("$@")
local status=0
if run_or_print "${cmd[@]}"; then
status=0
else
status=$?
fi
return "$status"
}
ensure_codex_directory() {
local path=$1
local label=$2
if [[ -L $path ]]; then
error "$label '$path' must not be a symlink"
fi
if [[ -e $path && ! -d $path ]]; then
error "expected $label '$path' to be a directory"
fi
if [[ -d $path ]]; then
return 0
fi
if $DRY_RUN; then
print_command mkdir -p "$path"
return 0
fi
mkdir -p "$path"
}
ensure_codex_storage_paths() {
local state_root="$CODEX_ROOT_HOST/sloptrap"
local state_bucket="$state_root/state"
ensure_codex_directory "$CODEX_ROOT_HOST" "Codex home"
ensure_codex_directory "$state_root" "sloptrap Codex namespace"
ensure_codex_directory "$state_bucket" "sloptrap Codex state root"
ensure_codex_directory "$CODEX_STATE_HOME_HOST" "project Codex state"
if [[ -L $CODEX_AUTH_FILE_HOST ]]; then
error "Codex auth file '$CODEX_AUTH_FILE_HOST' must not be a symlink"
fi
if [[ -e $CODEX_AUTH_FILE_HOST && ! -f $CODEX_AUTH_FILE_HOST ]]; then
error "expected Codex auth file '$CODEX_AUTH_FILE_HOST' to be a regular file"
fi
if [[ -f $CODEX_AUTH_FILE_HOST ]]; then
return 0
fi
if $DRY_RUN; then
print_command touch "$CODEX_AUTH_FILE_HOST"
return 0
fi
: > "$CODEX_AUTH_FILE_HOST"
}
ensure_opencode_storage_paths() {
local state_root="$CODEX_ROOT_HOST/sloptrap"
local state_bucket="$state_root/state"
ensure_codex_directory "$CODEX_ROOT_HOST" "Opencode home"
ensure_codex_directory "$state_root" "sloptrap Opencode namespace"
ensure_codex_directory "$state_bucket" "sloptrap Opencode state root"
ensure_codex_directory "$CODEX_STATE_HOME_HOST" "project Opencode state"
}
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
local response
if ! response=$(curl -fsSL "$api_url"); then
error "failed to download Codex release metadata from GitHub"
fi
local digest_line
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
digest_line=${digest_line#sha256:}
printf '%s' "$digest_line"
}
detect_opencode_archive_name() {
local os arch opencode_os opencode_arch
os=$(uname -s 2>/dev/null || true)
arch=$(uname -m 2>/dev/null || true)
[[ -n $os ]] || error "failed to detect host OS for opencode download"
[[ -n $arch ]] || error "failed to detect host architecture for opencode download"
case "$os" in
Linux|Darwin) opencode_os="unknown-linux-gnu" ;;
*) error "unsupported host OS '$os' for opencode download" ;;
esac
case "$arch" in
x86_64|amd64) opencode_arch="x86_64" ;;
arm64|aarch64) opencode_arch="arm64" ;;
*) error "unsupported host architecture '$arch' for opencode download" ;;
esac
printf 'opencode-%s-%s' "$opencode_arch" "$opencode_os"
}
fetch_latest_opencode_digest() {
local api_url="https://api.github.com/repos/anomalyco/opencode/releases/latest"
local target_asset="${SLOPTRAP_CODEX_BIN_NAME}.tar.gz"
[[ -n $SLOPTRAP_CODEX_BIN_NAME ]] || error "opencode binary name is not set"
if ! command -v jq >/dev/null 2>&1; then
error "jq is required to verify the opencode binary digest"
fi
local response
if ! response=$(curl -fsSL "$api_url"); then
error "failed to download opencode release metadata from GitHub"
fi
local digest_line
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 opencode digest from GitHub response"
fi
digest_line=${digest_line#sha256:}
printf '%s' "$digest_line"
}
ensure_codex_binary() {
prepare_build_context
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="$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
rm -rf "$download_dir"
error "failed to download Codex binary from '$SLOPTRAP_CODEX_URL'"
fi
local expected_digest
expected_digest=$(fetch_latest_codex_digest)
if ! printf "%s %s\n" "$expected_digest" "$tmp_archive" | sha256sum -c - >/dev/null 2>&1; then
rm -rf "$download_dir" "$CODEX_BIN_PATH"
error "checksum verification failed for $SLOPTRAP_CODEX_BIN_NAME"
fi
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 0755 "$CODEX_BIN_PATH"
}
ensure_opencode_binary() {
prepare_build_context
if [[ -x $CODEX_BIN_PATH ]]; then
return 0
fi
local tar_transform="s/${SLOPTRAP_CODEX_BIN_NAME}/${SLOPTRAP_CODEX_BIN_NAME}/"
local download_dir
download_dir=$(create_temp_dir "opencode")
local tmp_archive="$download_dir/${SLOPTRAP_CODEX_BIN_NAME}.tar.gz"
if $DRY_RUN; then
print_command curl -Lso "$tmp_archive" "https://github.com/anomalyco/opencode/releases/latest/download/${SLOPTRAP_CODEX_BIN_NAME}.tar.gz"
print_command sha256sum -c -
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" "https://github.com/anomalyco/opencode/releases/latest/download/${SLOPTRAP_CODEX_BIN_NAME}.tar.gz"; then
rm -rf "$download_dir"
error "failed to download opencode binary from https://github.com/anomalyco/opencode/releases/latest/download/${SLOPTRAP_CODEX_BIN_NAME}.tar.gz"
fi
local expected_digest
expected_digest=$(fetch_latest_opencode_digest)
if ! printf "%s %s\n" "$expected_digest" "$tmp_archive" | sha256sum -c - >/dev/null 2>&1; then
rm -rf "$download_dir" "$CODEX_BIN_PATH"
error "checksum verification failed for ${SLOPTRAP_CODEX_BIN_NAME}"
fi
if ! tar -xzf "$tmp_archive" --transform="$tar_transform" -C "$SLOPTRAP_BUILD_CONTEXT"; then
rm -rf "$download_dir"
error "failed to extract opencode binary"
fi
rm -rf "$download_dir"
chmod 0755 "$CODEX_BIN_PATH"
}
ensure_safe_sandbox() {
local -a args=("$@")
local sandbox_mode=""
local i=0
while [[ $i -lt ${#args[@]} ]]; do
if [[ ${args[$i]} == "--sandbox" ]]; then
if (( i + 1 >= ${#args[@]} )); then
error "runtime '--sandbox' flag requires a mode (workspace-write, workspace-read-only, or danger-full-access)"
fi
sandbox_mode="${args[$((i + 1))]}"
fi
((i+=1))
done
if [[ -z $sandbox_mode ]]; then
error "runtime flags must include '--sandbox <mode>' (workspace-write, workspace-read-only, or danger-full-access)"
fi
case "$sandbox_mode" in
workspace-write|workspace-read-only|danger-full-access)
;;
*)
error "sandbox mode '$sandbox_mode' is not allowed (expected workspace-write, workspace-read-only, or danger-full-access)"
;;
esac
}
normalize_package_list() {
local raw=$1
raw="$(trim "$raw")"
[[ -z $raw ]] && return 0
local -a tokens=()
read -r -a tokens <<< "$raw"
printf '%s' "${tokens[*]}"
}
targets_need_build() {
local -a targets=("$@")
if [[ ${#targets[@]} -eq 0 ]]; then
return 0
fi
local target
for target in "${targets[@]}"; do
case "$target" in
build|rebuild|build-if-missing|run|login|shell|resume)
return 0
;;
esac
done
return 1
}
prepare_container_runtime() {
resolve_container_workdir
SLOPTRAP_PACKAGES_EXTRA_RESOLVED=$PACKAGES_EXTRA
SLOPTRAP_RUN_AS_ROOT=false
SLOPTRAP_SHARED_DIR_ABS="$CODE_DIR"
if [[ ! -d $SLOPTRAP_SHARED_DIR_ABS ]]; then
error "shared directory '$SLOPTRAP_SHARED_DIR_ABS' does not exist"
fi
SLOPTRAP_SHARED_DIR_ABS="$(cd "$SLOPTRAP_SHARED_DIR_ABS" && pwd -P)"
local dockerfile_override
dockerfile_override=$(get_env_default "SLOPTRAP_DOCKERFILE_PATH" "")
if [[ -n $dockerfile_override ]]; then
if [[ $dockerfile_override != /* ]]; then
dockerfile_override="$SCRIPT_DIR/$dockerfile_override"
fi
if [[ ! -f $dockerfile_override ]]; then
error "container recipe '$dockerfile_override' not found"
fi
SLOPTRAP_DOCKERFILE_SOURCE="$dockerfile_override"
elif [[ -f "$SCRIPT_DIR/Dockerfile.sloptrap" ]]; then
SLOPTRAP_DOCKERFILE_SOURCE="$SCRIPT_DIR/Dockerfile.sloptrap"
else
SLOPTRAP_DOCKERFILE_SOURCE=""
fi
SLOPTRAP_PACKAGES_BASE=$(get_env_default "SLOPTRAP_PACKAGES" "curl bash ca-certificates libstdc++6 git ripgrep xxd file procps util-linux")
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")
SLOPTRAP_CODEX_GID=$(get_env_default "SLOPTRAP_CODEX_GID" "1337")
SLOPTRAP_SECURITY_OPTS_EXTRA=$(get_env_default "SLOPTRAP_SECURITY_OPTS_EXTRA" "")
local default_network="bridge"
if [[ $CONTAINER_ENGINE == "podman" ]]; then
if command -v slirp4netns >/dev/null 2>&1; then
default_network="slirp4netns"
else
warn "podman detected but 'slirp4netns' is missing; falling back to 'bridge' networking"
fi
fi
SLOPTRAP_NETWORK_NAME=$(get_env_default "SLOPTRAP_NETWORK_NAME" "$default_network")
if $ALLOW_HOST_NETWORK; then
SLOPTRAP_NETWORK_NAME="host"
elif [[ $SLOPTRAP_NETWORK_NAME == "host" ]]; then
error "$MANIFEST_PATH: host networking requires allow_host_network=true"
fi
SLOPTRAP_LIMITS_PID=$(get_env_default "SLOPTRAP_LIMITS_PID" "1024")
SLOPTRAP_LIMITS_RAM=$(get_env_default "SLOPTRAP_LIMITS_RAM" "1024m")
SLOPTRAP_LIMITS_SWP=$(get_env_default "SLOPTRAP_LIMITS_SWP" "1024m")
SLOPTRAP_LIMITS_SHM=$(get_env_default "SLOPTRAP_LIMITS_SHM" "1024m")
SLOPTRAP_LIMITS_CPU=$(get_env_default "SLOPTRAP_LIMITS_CPU" "8")
SLOPTRAP_TMPFS_PATHS=$(get_env_default "SLOPTRAP_TMPFS_PATHS" "/tmp:exec /run /run/lock")
SLOPTRAP_ROOTFS_READONLY_DEFAULT=$(get_env_default "SLOPTRAP_ROOTFS_READONLY" "1")
SLOPTRAP_ROOTFS_READONLY=$SLOPTRAP_ROOTFS_READONLY_DEFAULT
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")
# Setup opencode state paths
if [[ "$BACKEND" == "opencode" ]]; then
ensure_opencode_storage_paths
fi
if [[ -n $SLOPTRAP_SECURITY_OPTS_EXTRA ]]; then
local -a extra_opts=()
read -r -a extra_opts <<< "$SLOPTRAP_SECURITY_OPTS_EXTRA"
security_opts+=("${extra_opts[@]}")
fi
local -a resource_opts=(
--pids-limit "$SLOPTRAP_LIMITS_PID"
--memory "$SLOPTRAP_LIMITS_RAM"
--memory-swap "$SLOPTRAP_LIMITS_SWP"
--shm-size "$SLOPTRAP_LIMITS_SHM"
--cpus "$SLOPTRAP_LIMITS_CPU"
)
local -a tmpfs_opts=()
if [[ -n $SLOPTRAP_TMPFS_PATHS ]]; then
local -a tmpfs_paths=()
read -r -a tmpfs_paths <<< "$SLOPTRAP_TMPFS_PATHS"
local path
for path in "${tmpfs_paths[@]}"; do
tmpfs_opts+=(--tmpfs "$path")
done
fi
security_opts+=(--security-opt no-new-privileges)
local rootfs_flag=()
case "${SLOPTRAP_ROOTFS_READONLY,,}" in
1|true|yes)
rootfs_flag=(--read-only)
;;
esac
if [[ $CONTAINER_ENGINE == "podman" ]]; then
SLOPTRAP_VOLUME_LABEL=":Z"
else
SLOPTRAP_VOLUME_LABEL=""
fi
local -a volume_opts=(
-v "$SLOPTRAP_SHARED_DIR_ABS:$SLOPTRAP_WORKDIR$SLOPTRAP_VOLUME_LABEL"
-v "$CODEX_STATE_HOME_HOST:$SLOPTRAP_CODEX_HOME_CONT$SLOPTRAP_VOLUME_LABEL"
)
# Add opencode state mount if using opencode backend
if [[ "$BACKEND" == "opencode" ]]; then
volume_opts+=(-v "$OPENCODE_STATE_HOME_HOST:$SLOPTRAP_CODEX_HOME_CONT/state/opencode$SLOPTRAP_VOLUME_LABEL")
fi
local -a env_args=(
-e "HOME=$SLOPTRAP_CODEX_HOME_CONT"
-e "XDG_CONFIG_HOME=$SLOPTRAP_CODEX_HOME_CONT/config"
-e "XDG_CACHE_HOME=$SLOPTRAP_CODEX_HOME_CONT/cache"
-e "XDG_STATE_HOME=$SLOPTRAP_CODEX_HOME_CONT/state"
-e "CODEX_HOME=$SLOPTRAP_CODEX_HOME_CONT"
-e "SLOPTRAP_WORKDIR=$SLOPTRAP_WORKDIR"
-e "SLOPTRAP_HELPER_DIR=/tmp/sloptrap-helper"
)
# Add opencode-specific environment variables
if [[ "$BACKEND" == "opencode" ]]; then
env_args+=(
-e "OPENCODE_HOME=$SLOPTRAP_CODEX_HOME_CONT"
-e "OPENCODE_SERVER=$OPENCODE_SERVER"
-e "OPENCODE_MODEL=$OPENCODE_MODEL"
)
fi
local uid gid user
uid=$(id -u)
gid=$(id -g)
user=$(id -un 2>/dev/null || true)
env_args+=(
-e "SLOPTRAP_HOST_UID=$uid"
-e "SLOPTRAP_HOST_GID=$gid"
)
if [[ -n $user ]]; then
env_args+=(-e "SLOPTRAP_HOST_USER=$user")
fi
local -a network_opts=(--network "$SLOPTRAP_NETWORK_NAME")
local -a user_opts=("--user" "$uid:$gid")
if [[ $CONTAINER_ENGINE == "podman" ]]; then
user_opts=(--userns="keep-id:uid=$uid,gid=$gid" "${user_opts[@]}")
fi
if $SLOPTRAP_RUN_AS_ROOT; then
user_opts=()
if [[ $CONTAINER_ENGINE == "podman" ]]; then
user_opts=(--userns="keep-id:uid=$uid,gid=$gid")
fi
fi
CONTAINER_SHARED_OPTS=(
"${network_opts[@]}"
"${security_opts[@]}"
"${resource_opts[@]}"
"${rootfs_flag[@]}"
"${tmpfs_opts[@]}"
"${volume_opts[@]}"
"${env_args[@]}"
"${IGNORE_MOUNT_ARGS[@]}"
"${user_opts[@]}"
-w "$SLOPTRAP_WORKDIR"
)
BASE_CONTAINER_CMD=(
"$CONTAINER_ENGINE" run --rm -it
--name "$SLOPTRAP_CONTAINER_NAME"
"${CONTAINER_SHARED_OPTS[@]}"
)
}
build_image() {
if [[ "$BACKEND" == "opencode" ]]; then
ensure_opencode_binary
else
ensure_codex_binary
fi
if [[ $SKIP_BUILD_BANNER != true ]]; then
print_banner
fi
print_manifest_summary
local extra_packages_arg
extra_packages_arg=$(normalize_package_list "$SLOPTRAP_PACKAGES_EXTRA_RESOLVED")
if ! $DRY_RUN; then
status_line "Building %s\n" "$SLOPTRAP_IMAGE_NAME"
fi
local -a cmd=(
"$CONTAINER_ENGINE" build --quiet
-t "$SLOPTRAP_IMAGE_NAME"
-f "$SLOPTRAP_DOCKERFILE_PATH"
--network "$SLOPTRAP_NETWORK_NAME"
--label "$SLOPTRAP_IMAGE_LABEL"
--build-arg "BASE_PACKAGES=$SLOPTRAP_PACKAGES_BASE"
--build-arg "CODEX_BIN=$SLOPTRAP_CODEX_BIN_NAME"
--build-arg "CODEX_UID=$SLOPTRAP_CODEX_UID"
--build-arg "CODEX_GID=$SLOPTRAP_CODEX_GID"
)
if [[ -n $extra_packages_arg ]]; then
cmd+=(--build-arg "EXTRA_PACKAGES=$extra_packages_arg")
fi
cmd+=("$SLOPTRAP_BUILD_CONTEXT")
if $DRY_RUN; then
run_or_print "${cmd[@]}"
return
fi
local build_output
if ! build_output=$("${cmd[@]}"); then
return 1
fi
build_output=$(trim "$build_output")
if [[ -n $build_output ]]; then
comment_line "Image %s\n" "$build_output"
fi
}
rebuild_image() {
if [[ "$BACKEND" == "opencode" ]]; then
ensure_opencode_binary
else
ensure_codex_binary
fi
if [[ $SKIP_BUILD_BANNER != true ]]; then
print_banner
fi
print_manifest_summary
if ! $DRY_RUN; then
status_line "Rebuilding %s (no cache)\n" "$SLOPTRAP_IMAGE_NAME"
fi
local extra_packages_arg
extra_packages_arg=$(normalize_package_list "$SLOPTRAP_PACKAGES_EXTRA_RESOLVED")
local -a cmd=(
"$CONTAINER_ENGINE" build --no-cache --quiet
-t "$SLOPTRAP_IMAGE_NAME"
-f "$SLOPTRAP_DOCKERFILE_PATH"
--network "$SLOPTRAP_NETWORK_NAME"
--label "$SLOPTRAP_IMAGE_LABEL"
--build-arg "BASE_PACKAGES=$SLOPTRAP_PACKAGES_BASE"
--build-arg "CODEX_BIN=$SLOPTRAP_CODEX_BIN_NAME"
--build-arg "CODEX_UID=$SLOPTRAP_CODEX_UID"
--build-arg "CODEX_GID=$SLOPTRAP_CODEX_GID"
)
if [[ -n $extra_packages_arg ]]; then
cmd+=(--build-arg "EXTRA_PACKAGES=$extra_packages_arg")
fi
cmd+=("$SLOPTRAP_BUILD_CONTEXT")
if $DRY_RUN; then
run_or_print "${cmd[@]}"
return
fi
local build_output
if ! build_output=$("${cmd[@]}"); then
return 1
fi
build_output=$(trim "$build_output")
if [[ -n $build_output ]]; then
comment_line "Image %s\n" "$build_output"
fi
}
build_if_missing() {
if $DRY_RUN; then
print_command "$CONTAINER_ENGINE" image inspect "$SLOPTRAP_IMAGE_NAME"
return 0
fi
if "$CONTAINER_ENGINE" image inspect "$SLOPTRAP_IMAGE_NAME" >/dev/null 2>&1; then
local created
if created=$("$CONTAINER_ENGINE" image inspect --format '{{.Created}}' "$SLOPTRAP_IMAGE_NAME" 2>/dev/null); then
local created_epoch
created_epoch=$(date -d "$created" +%s 2>/dev/null || true)
if [[ -n $created_epoch ]]; then
local now_epoch
now_epoch=$(date +%s)
local age_days=$(( (now_epoch - created_epoch) / 86400 ))
if (( age_days > 30 )); then
warn "image '$SLOPTRAP_IMAGE_NAME' is ${age_days} days old; rebuilding"
rebuild_image
fi
fi
fi
return 0
fi
build_image
}
stop_container() {
if $DRY_RUN; then
print_command "$CONTAINER_ENGINE" stop "$SLOPTRAP_CONTAINER_NAME"
return 0
fi
"$CONTAINER_ENGINE" stop "$SLOPTRAP_CONTAINER_NAME" >/dev/null 2>&1 || true
}
clean_environment() {
if ! $DRY_RUN; then
status_line "Cleaning %s\n" "$PROJECT_NAME"
fi
local helper_root="$CODE_DIR/.sloptrap-ignores"
if [[ -L $helper_root ]]; then
error "$helper_root: helper directory may not be a symlink"
fi
assert_path_within_code_dir "$helper_root"
stop_container
if $DRY_RUN; then
print_command "$CONTAINER_ENGINE" rm -f "$SLOPTRAP_CONTAINER_NAME"
print_command "$CONTAINER_ENGINE" rmi "$SLOPTRAP_IMAGE_NAME"
print_command rm -rf "$helper_root"
return 0
fi
"$CONTAINER_ENGINE" rm -f "$SLOPTRAP_CONTAINER_NAME" >/dev/null 2>&1 || true
"$CONTAINER_ENGINE" rmi "$SLOPTRAP_IMAGE_NAME" >/dev/null 2>&1 || true
rm -rf "$helper_root"
}
prune_sloptrap_images() {
if ! $DRY_RUN; then
status_line "Pruning unused sloptrap images\n"
fi
local -a cmd=(
"$CONTAINER_ENGINE" image prune --force --all
--filter "label=$SLOPTRAP_IMAGE_LABEL"
)
run_or_print "${cmd[@]}"
}
run_codex_command() {
local -a extra_args=("$@")
local -a source_args=("$SLOPTRAP_IMAGE_NAME")
local -a auth_mount=()
if [[ "$BACKEND" == "opencode" ]]; then
ensure_opencode_storage_paths
else
ensure_codex_storage_paths
fi
append_auth_mount_arg false auth_mount
local -a cmd=("${BASE_CONTAINER_CMD[@]}" "${auth_mount[@]}" "${source_args[@]}" "$BACKEND")
if [[ "$BACKEND" == "opencode" ]]; then
cmd+=("--server" "$OPENCODE_SERVER")
cmd+=("--model" "$OPENCODE_MODEL")
cmd+=("--sandbox" "workspace-write")
else
if [[ ${#CODEX_ARGS_ARRAY[@]} -gt 0 ]]; then
cmd+=("${CODEX_ARGS_ARRAY[@]}")
fi
fi
if [[ ${#extra_args[@]} -gt 0 ]]; then
cmd+=("${extra_args[@]}")
fi
run_runtime_container_cmd "${cmd[@]}"
}
run_codex() {
if ! $DRY_RUN; then
status_line "Running %s\n" "$SLOPTRAP_IMAGE_NAME"
fi
local runtime_prompt
runtime_prompt=$(build_runtime_context_prompt)
run_codex_command "$runtime_prompt"
}
run_login_target() {
ensure_codex_storage_paths
local -a source_args=("$SLOPTRAP_IMAGE_NAME")
local -a auth_mount=()
if ! $DRY_RUN; then
status_line "Login %s\n" "$SLOPTRAP_IMAGE_NAME"
fi
append_auth_mount_arg true auth_mount
local -a cmd=("${BASE_CONTAINER_CMD[@]}" "${auth_mount[@]}" "${source_args[@]}" "codex" login)
run_runtime_container_cmd "${cmd[@]}"
}
run_shell_target() {
ensure_codex_storage_paths
local -a source_args=("$SLOPTRAP_IMAGE_NAME")
local -a auth_mount=()
if ! $DRY_RUN; then
status_line "Shell %s\n" "$SLOPTRAP_IMAGE_NAME"
fi
append_auth_mount_arg false auth_mount
local -a cmd=("${BASE_CONTAINER_CMD[@]}" "${auth_mount[@]}" "${source_args[@]}" /bin/bash)
run_runtime_container_cmd "${cmd[@]}"
}
run_resume_target() {
local session_id=$1
if ! $DRY_RUN; then
status_line "Resume %s (%s)\n" "$SLOPTRAP_IMAGE_NAME" "$session_id"
fi
run_codex_command resume "$session_id"
}
process_resume_target() {
local session_id=$1
if [[ -z $session_id ]]; then
error "target 'resume' requires a session identifier"
fi
build_if_missing
run_resume_target "$session_id"
}
dispatch_target() {
local target=$1
case "$target" in
build)
build_image
;;
rebuild)
rebuild_image
;;
build-if-missing)
build_if_missing
;;
run)
build_if_missing
run_codex
;;
login)
build_if_missing
run_login_target
;;
shell)
build_if_missing
run_shell_target
;;
wizard)
run_wizard "$MANIFEST_PATH"
exit 0
;;
stop)
stop_container
;;
clean)
clean_environment
;;
prune)
prune_sloptrap_images
;;
*)
error "unknown target '$target'"
;;
esac
}
DRY_RUN=false
PRINT_CONFIG=false
SKIP_BUILD_BANNER=false
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run)
DRY_RUN=true
shift
;;
--print-config)
PRINT_CONFIG=true
shift
;;
-h|--help)
usage
exit 0
;;
--)
shift
break
;;
-*)
usage >&2
error "unknown flag '$1'"
;;
*)
break
;;
esac
done
if [[ $# -lt 1 ]]; then
usage >&2
exit 1
fi
CODE_DIR_INPUT=$1
shift
if [[ ! -d $CODE_DIR_INPUT ]]; then
error "code directory '$CODE_DIR_INPUT' does not exist"
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"
TARGETS_INPUT=("$@")
if [[ -f $MANIFEST_PATH ]]; then
MANIFEST_PRESENT=true
parse_manifest "$MANIFEST_PATH"
fi
if [[ ${#TARGETS_INPUT[@]} -gt 0 ]]; then
target_index=0
while (( target_index < ${#TARGETS_INPUT[@]} )); do
if [[ ${TARGETS_INPUT[$target_index]} == "wizard" ]]; then
if (( ${#TARGETS_INPUT[@]} > 1 )); then
warn "wizard runs standalone; ignoring other targets"
fi
run_wizard "$MANIFEST_PATH"
exit 0
fi
((target_index+=1))
done
fi
if [[ ! -f $MANIFEST_PATH ]]; then
if targets_need_build "${TARGETS_INPUT[@]}"; then
if [[ -t 0 ]]; then
run_wizard "$MANIFEST_PATH"
SKIP_BUILD_BANNER=true
MANIFEST=()
MANIFEST_PRESENT=true
parse_manifest "$MANIFEST_PATH"
else
warn "missing $MANIFEST_BASENAME; proceeding with defaults (run '$0 $CODE_DIR wizard' to create one)"
fi
fi
fi
PROJECT_NAME=${MANIFEST[name]-$(basename "$CODE_DIR")}
if [[ -z $PROJECT_NAME ]]; then
error "$MANIFEST_PATH: project name resolved to empty string"
fi
if [[ ! $PROJECT_NAME =~ $VALID_NAME_REGEX ]]; then
error "$MANIFEST_PATH: invalid project name '$PROJECT_NAME' (allowed: letters, digits, ., _, -)"
fi
select_codex_home "$CODE_DIR"
ensure_ignore_helper_root
IGNORE_STUB_BASE="$IGNORE_HELPER_ROOT/session-${BASHPID:-$$}"
resolve_sloptrap_ignore "$CODE_DIR"
resolve_container_workdir
NEED_LOGIN=false
if [[ ! -s "$CODEX_AUTH_FILE_HOST" ]]; then
NEED_LOGIN=true
fi
TARGETS=("${TARGETS_INPUT[@]}")
if [[ ${#TARGETS[@]} -eq 0 ]]; then
TARGETS=("run")
fi
DEFAULT_TARGETS=("${TARGETS[@]}")
PACKAGES_EXTRA=${MANIFEST[packages_extra]-}
if [[ -n ${MANIFEST[allow_host_network]-} ]]; then
case "${MANIFEST[allow_host_network],,}" in
1|true|yes)
ALLOW_HOST_NETWORK=true
;;
0|false|no)
ALLOW_HOST_NETWORK=false
;;
*)
error "$MANIFEST_PATH: allow_host_network must be true or false (got '${MANIFEST[allow_host_network]}')"
;;
esac
fi
forbidden_keys=(codex_args container_opts_extra security_opts_extra env_extra env_passthrough default_targets default_target)
for forbidden_key in "${forbidden_keys[@]}"; do
if [[ -n ${MANIFEST[$forbidden_key]-} ]]; then
if [[ $forbidden_key == "codex_args" ]]; then
error "$MANIFEST_PATH: key 'codex_args' has been deprecated; sloptrap now always uses '$DEFAULT_CODEX_ARGS_DISPLAY'"
fi
error "$MANIFEST_PATH: key '$forbidden_key' has been removed for security reasons"
fi
done
if [[ -n $PACKAGES_EXTRA ]]; then
ensure_safe_for_make "packages_extra" "$PACKAGES_EXTRA"
validate_package_list "packages_extra" "$PACKAGES_EXTRA"
fi
select_backend() {
local manifest_agent="${MANIFEST[agent]:-codex}"
local env_agent="${SLOPTRAP_AGENT:-}"
[[ -n $env_agent ]] && manifest_agent="$env_agent"
case "${manifest_agent,,}" in
opencode) BACKEND="opencode" ;;
codex|*) BACKEND="codex" ;;
esac
if [[ "$BACKEND" == "opencode" && ! -x "$(command -v opencode)" ]]; then
error "opencode CLI not found; install from https://opencode.ai"
fi
}
select_backend
if [[ "$BACKEND" == "opencode" ]]; then
OPENCODE_SERVER="${MANIFEST[opencode_server]:-http://localhost:11434}"
OPENCODE_MODEL="${MANIFEST[opencode_model]:-llama3}"
OPENCODE_STATE_HOME_HOST="$CODEX_STATE_HOME_HOST/opencode"
else
OPENCODE_SERVER=""
OPENCODE_MODEL=""
OPENCODE_STATE_HOME_HOST=""
fi
CONTAINER_ENGINE="$(detect_container_engine)"
CODEX_ARGS_ARRAY=("${DEFAULT_CODEX_ARGS[@]}")
ensure_safe_sandbox "${CODEX_ARGS_ARRAY[@]}"
CODEX_ARGS_DISPLAY=$DEFAULT_CODEX_ARGS_DISPLAY
prepare_ignore_mounts "$CODE_DIR"
prepare_container_runtime
if $PRINT_CONFIG; then
print_config "$MANIFEST_PATH"
exit 0
fi
AUTO_LOGIN=false
if [[ $NEED_LOGIN == true ]]; then
AUTO_LOGIN=true
for tgt in "${TARGETS[@]}"; do
if [[ $tgt == "login" ]]; then
AUTO_LOGIN=false
break
fi
done
fi
if $AUTO_LOGIN; then
if ! $DRY_RUN; then
status_line "Codex login required (%s)\n" "$CODEX_AUTH_FILE_HOST"
fi
dispatch_target login
fi
target_index=0
while (( target_index < ${#TARGETS[@]} )); do
current_target="${TARGETS[$target_index]}"
if [[ $current_target == "resume" ]]; then
if (( target_index + 1 >= ${#TARGETS[@]} )); then
error "target 'resume' requires a session identifier"
fi
process_resume_target "${TARGETS[$((target_index + 1))]}"
((target_index+=2))
continue
fi
dispatch_target "$current_target"
((target_index+=1))
done