From 0a8b6e78aa2ca094433a27eb599b90c7568992d8 Mon Sep 17 00:00:00 2001 From: Timo Bigdon Date: Tue, 7 Jul 2026 22:16:31 +0200 Subject: [PATCH] Fix codex user and isolate instance homes --- .github/workflows/ci.yml | 11 ++++ Containerfile | 7 ++- README.md | 31 ++++++---- docs/runtime-notes.md | 18 ++++-- scripts/lib-codexcli-runtime.sh | 67 +++++++++++++++++++-- tests/runtime-home-selection.sh | 101 ++++++++++++++++++++++++++++++++ 6 files changed, 214 insertions(+), 21 deletions(-) create mode 100644 tests/runtime-home-selection.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8cf6f5..a015bdd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,11 +29,22 @@ jobs: - name: Run ShellCheck run: | shellcheck -x scripts/* + shellcheck -x tests/* + + - name: Run runtime tests + run: | + bash tests/runtime-home-selection.sh - name: Build runtime image run: | podman build -t codexcli-runtime-ci-smoke . + - name: Check runtime user + run: | + podman run --rm codexcli-runtime-ci-smoke getent passwd codex + podman run --rm codexcli-runtime-ci-smoke id codex + podman run --rm codexcli-runtime-ci-smoke sh -lc 'test -d /home/codex/.codex && test -w /home/codex/.codex' + - name: Check expected files run: | test -f Containerfile diff --git a/Containerfile b/Containerfile index b25881b..0d61d90 100644 --- a/Containerfile +++ b/Containerfile @@ -22,11 +22,16 @@ RUN apt-get update \ RUN npm install -g @openai/codex@${CODEX_VERSION} -RUN mkdir -p /home/codex/.codex && chmod -R 0777 /home/codex +RUN useradd --create-home --user-group --home-dir /home/codex --shell /bin/bash codex \ + && mkdir -p /home/codex/.codex \ + && chown -R codex:codex /home/codex \ + && chmod 0777 /home/codex/.codex WORKDIR /workspace ENV HOME=/home/codex ENV CODEX_HOME=/home/codex/.codex +USER codex + CMD ["codex"] diff --git a/README.md b/README.md index 1d600e0..69a0b28 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,10 @@ ln -s "$PWD/scripts/codexcli-login" ~/bin/codexcli-login ln -s "$PWD/scripts/codexcli-strict" ~/bin/codexcli-strict ``` -The wrappers keep Codex state in -`~/.local/share/codexcli/codex-home`. The default runtime image is generic and -unchanged: `eigen-codexcli:latest`. +The wrappers keep default Codex state in +`~/.local/share/codexcli/codex-home`. Named instances use isolated Codex homes +under `~/.local/share/codexcli/instances///codex-home`. +The default runtime image is generic and unchanged: `eigen-codexcli:latest`. The image includes Poppler PDF text extraction tools such as `pdftotext`, `pdfinfo`, and `pdftohtml`. @@ -42,7 +43,7 @@ want these operating rules to apply across sessions. identity, PDF tooling, branch, and worktree status without launching Codex. Named instances let you intentionally run more than one container for the same -checkout: +checkout, with separate container names and separate writable Codex state: ```sh codexcli --instance review @@ -78,6 +79,11 @@ it after `--`, for example `codexcli -- --image value`. All other positional arguments are passed unchanged to Codex. +Project images can temporarily switch to `USER root` for package installs, but +should return to `USER codex` when the image is ready to run. The generic +launcher may still run the container process with the host UID/GID for +bind-mounted workspace write compatibility. + ## Troubleshooting project tools If a repository tool fails with `ModuleNotFoundError` or a missing binary, stop. @@ -116,15 +122,16 @@ parallel. Named instances also have distinct container names. A wrapper refuses to start when its exact container name already exists; inspect or remove that container explicitly before retrying. -Named instances in the same checkout share the mounted workspace. Use them only -for read-only work or clearly disjoint files. For overlapping tracked-code -work, use separate Git worktrees instead. Container separation does not prevent -agents from editing the same files, index, or branch, so changes can be -overwritten or combined unpredictably. +Named instances in the same checkout share the mounted workspace and Git state, +but not Codex writable state. Use them only for read-only work or clearly +disjoint files. For overlapping tracked-code work, use separate Git worktrees +instead. Container separation does not prevent agents from editing the same +files, index, or branch, so changes can be overwritten or combined +unpredictably. -All instances share `~/.local/share/codexcli/codex-home` by default. This is -useful for auth and config reuse, but sessions, history, and lock files may -also be shared. +When a named instance is first used, its Codex home is seeded from the default +`~/.local/share/codexcli/codex-home` if that directory exists. The default home +is not moved or deleted. See `docs/runtime-notes.md` for runtime caveats, including Git hook handling in mounted repositories. diff --git a/docs/runtime-notes.md b/docs/runtime-notes.md index d4658e2..b8b2b26 100644 --- a/docs/runtime-notes.md +++ b/docs/runtime-notes.md @@ -18,6 +18,11 @@ The generic runtime intentionally does not include every project's dependencies, and project images should be explicit and reviewable. Missing dependencies should fail loudly instead of causing shims or local workarounds. +Project images may temporarily use `USER root` for `apt-get` or other install +steps, then should return to `USER codex`. The base runtime provides the +`codex` user and `/home/codex/.codex`, while the launcher may still override +the process user to the host UID/GID for bind-mounted workspace compatibility. + ## Git hooks and repository trust The runtime sets `core.hooksPath=/dev/null` inside the container so Git commands @@ -31,10 +36,15 @@ Git operations that may execute hooks or rely on repository-local config. ## Named instances -Named instances isolate container names, not the workspace. They do not isolate -the Git index, current branch, Git common directory, or Codex home. Use them for -read-only checks or clearly disjoint files; use separate Git worktrees for real -parallel implementation work. +Named instances isolate container names and Codex writable state, not the +workspace. They do not isolate the Git index, current branch, or Git common +directory. Use them for read-only checks or clearly disjoint files; use separate +Git worktrees for real parallel implementation work. + +Default runs use `~/.local/share/codexcli/codex-home`. Named instances use +`~/.local/share/codexcli/instances///codex-home` and +seed that directory from the default Codex home on first use when the default +exists. The default Codex home is not moved or deleted. `codexcli-login` parallelism is still limited by the fixed `127.0.0.1:1455` login callback port. diff --git a/scripts/lib-codexcli-runtime.sh b/scripts/lib-codexcli-runtime.sh index 1f5bd74..5f0cc43 100755 --- a/scripts/lib-codexcli-runtime.sh +++ b/scripts/lib-codexcli-runtime.sh @@ -6,6 +6,10 @@ codexcli_die() { } codexcli_container_name() { + printf 'codexcli-%s\n' "$(codexcli_project_key)" +} + +codexcli_project_key() { local workspace_basename workspace_hash workspace_basename="$(basename "$CODEXCLI_WORKSPACE")" @@ -14,7 +18,7 @@ codexcli_container_name() { workspace_hash="$(printf '%s' "$CODEXCLI_WORKSPACE" \ | sha256sum | cut -c 1-12)" - printf 'codexcli-%s-%s\n' "$workspace_basename" "$workspace_hash" + printf '%s-%s\n' "$workspace_basename" "$workspace_hash" } codexcli_validate_instance() { @@ -99,6 +103,51 @@ codexcli_project_env_files() { printf '%s' "${found:-}" } +codexcli_default_home_host() { + printf '%s/.local/share/codexcli/codex-home\n' "$HOME" +} + +codexcli_instance_home_host() { + printf '%s/.local/share/codexcli/instances/%s/%s/codex-home\n' \ + "$HOME" "$CODEXCLI_PROJECT_KEY" "$CODEXCLI_INSTANCE" +} + +codexcli_select_home_host() { + CODEXCLI_DEFAULT_HOME_HOST="$(codexcli_default_home_host)" + if [ -n "$CODEXCLI_INSTANCE" ]; then + CODEXCLI_HOME_HOST="$(codexcli_instance_home_host)" + CODEXCLI_HOME_SOURCE="instance-isolated" + else + CODEXCLI_HOME_HOST="$CODEXCLI_DEFAULT_HOME_HOST" + CODEXCLI_HOME_SOURCE="default" + fi +} + +codexcli_prepare_home_host() { + if [ "$CODEXCLI_HOME_SOURCE" = "instance-isolated" ]; then + if [ ! -d "$CODEXCLI_HOME_HOST" ]; then + mkdir -p "$(dirname "$CODEXCLI_HOME_HOST")" + if [ -d "$CODEXCLI_DEFAULT_HOME_HOST" ]; then + cp -a "$CODEXCLI_DEFAULT_HOME_HOST" "$CODEXCLI_HOME_HOST" + else + mkdir -p "$CODEXCLI_HOME_HOST" + fi + fi + else + mkdir -p "$CODEXCLI_HOME_HOST" + fi +} + +codexcli_home_writable_status() { + if [ -d "$CODEXCLI_HOME_HOST" ] && [ -w "$CODEXCLI_HOME_HOST" ]; then + printf 'writable' + elif [ -e "$CODEXCLI_HOME_HOST" ]; then + printf 'not writable' + else + printf 'not created' + fi +} + codexcli_run() { local doctor=false local git_common_mount workspace_mount @@ -221,20 +270,22 @@ codexcli_run() { || codexcli_die "current directory is not inside a Git repository" CODEXCLI_GIT_COMMON_DIR="$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)" \ || codexcli_die "could not resolve Git common directory" + CODEXCLI_PROJECT_KEY="$(codexcli_project_key)" CODEXCLI_BASE_CONTAINER_NAME="$(codexcli_container_name)" CODEXCLI_CONTAINER_NAME="$(codexcli_effective_container_name)" CODEXCLI_INSTANCE_DISPLAY="${CODEXCLI_INSTANCE:-}" - CODEXCLI_HOME_HOST="${HOME}/.local/share/codexcli/codex-home" + codexcli_select_home_host codexcli_require_git_identity if [ "$doctor" = true ]; then CODEXCLI_PROJECT_ENV_FILES="$(codexcli_project_env_files)" fi if [ "$doctor" = false ]; then - mkdir -p "$CODEXCLI_HOME_HOST" + codexcli_prepare_home_host workspace_mount="$CODEXCLI_WORKSPACE:/workspace:Z" git_common_mount="$CODEXCLI_GIT_COMMON_DIR:$CODEXCLI_GIT_COMMON_DIR:Z" else + CODEXCLI_HOME_WRITABLE="$(codexcli_home_writable_status)" workspace_mount="$CODEXCLI_WORKSPACE:/workspace:ro" git_common_mount="$CODEXCLI_GIT_COMMON_DIR:$CODEXCLI_GIT_COMMON_DIR:ro" fi @@ -269,6 +320,7 @@ codexcli_run() { --security-opt label=disable -e GIT_OPTIONAL_LOCKS=0 -e "CODEXCLI_DOCTOR_WORKSPACE=$CODEXCLI_WORKSPACE" + -e "CODEXCLI_DOCTOR_PROJECT_KEY=$CODEXCLI_PROJECT_KEY" -e "CODEXCLI_DOCTOR_BASE_CONTAINER_NAME=$CODEXCLI_BASE_CONTAINER_NAME" -e "CODEXCLI_DOCTOR_INSTANCE=$CODEXCLI_INSTANCE_DISPLAY" -e "CODEXCLI_DOCTOR_CONTAINER_NAME=$CODEXCLI_CONTAINER_NAME" @@ -276,6 +328,9 @@ codexcli_run() { -e "CODEXCLI_DOCTOR_RUNTIME_IMAGE_SOURCE=$CODEXCLI_RUNTIME_IMAGE_SOURCE" -e "CODEXCLI_DOCTOR_PROJECT_ENV_FILES=$CODEXCLI_PROJECT_ENV_FILES" -e "CODEXCLI_DOCTOR_HOME_HOST=$CODEXCLI_HOME_HOST" + -e "CODEXCLI_DOCTOR_HOME_SOURCE=$CODEXCLI_HOME_SOURCE" + -e "CODEXCLI_DOCTOR_HOME_WRITABLE=$CODEXCLI_HOME_WRITABLE" + -e "CODEXCLI_DOCTOR_USER_EXPECTATION=host uid/gid via --userns=keep-id" ) else podman_args+=(-v "$CODEXCLI_HOME_HOST:/home/codex/.codex:Z") @@ -301,14 +356,18 @@ codexcli_run() { } printf "workspace: %s\n" "$CODEXCLI_DOCTOR_WORKSPACE" + printf "project key: %s\n" "$CODEXCLI_DOCTOR_PROJECT_KEY" printf "base container name: %s\n" "$CODEXCLI_DOCTOR_BASE_CONTAINER_NAME" printf "instance: %s\n" "$CODEXCLI_DOCTOR_INSTANCE" printf "effective container name: %s\n" "$CODEXCLI_DOCTOR_CONTAINER_NAME" printf "runtime image: %s\n" "$CODEXCLI_DOCTOR_RUNTIME_IMAGE" printf "runtime image source: %s\n" "$CODEXCLI_DOCTOR_RUNTIME_IMAGE_SOURCE" printf "project environment files: %s\n" "$CODEXCLI_DOCTOR_PROJECT_ENV_FILES" - printf "host Codex home (normal mode): %s\n" "$CODEXCLI_DOCTOR_HOME_HOST" + printf "selected Codex home: %s\n" "$CODEXCLI_DOCTOR_HOME_HOST" + printf "Codex home source: %s\n" "$CODEXCLI_DOCTOR_HOME_SOURCE" + printf "Codex home writable check: %s\n" "$CODEXCLI_DOCTOR_HOME_WRITABLE" printf "container CODEX_HOME: %s\n" "$CODEX_HOME" + printf "container user expectation: %s\n" "$CODEXCLI_DOCTOR_USER_EXPECTATION" printf "doctor mode: temporary Codex home; workspace and Git mounts are read-only\n" pwd python3 --version diff --git a/tests/runtime-home-selection.sh b/tests/runtime-home-selection.sh new file mode 100644 index 0000000..961cfea --- /dev/null +++ b/tests/runtime-home-selection.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=scripts/lib-codexcli-runtime.sh +source "$ROOT_DIR/scripts/lib-codexcli-runtime.sh" + +fail() { + printf 'not ok - %s\n' "$*" >&2 + exit 1 +} + +assert_eq() { + [ "$1" = "$2" ] || fail "expected '$2', got '$1'" +} + +assert_ne() { + [ "$1" != "$2" ] || fail "expected different values, got '$1'" +} + +setup_case() { + TEST_TMP="$(mktemp -d)" + HOME="$TEST_TMP/home" + CODEXCLI_WORKSPACE="$TEST_TMP/workspace" + mkdir -p "$HOME" "$CODEXCLI_WORKSPACE" + CODEXCLI_PROJECT_KEY="$(codexcli_project_key)" + CODEXCLI_INSTANCE='' +} + +cleanup_case() { + : +} + +test_default_home() { + setup_case + codexcli_select_home_host + assert_eq "$CODEXCLI_HOME_SOURCE" "default" + assert_eq "$CODEXCLI_HOME_HOST" "$HOME/.local/share/codexcli/codex-home" + cleanup_case +} + +test_instance_home() { + setup_case + CODEXCLI_INSTANCE='review' + codexcli_select_home_host + assert_eq "$CODEXCLI_HOME_SOURCE" "instance-isolated" + assert_eq "$CODEXCLI_HOME_HOST" "$HOME/.local/share/codexcli/instances/$CODEXCLI_PROJECT_KEY/review/codex-home" + cleanup_case +} + +test_two_instances_differ() { + setup_case + CODEXCLI_INSTANCE='review' + codexcli_select_home_host + first="$CODEXCLI_HOME_HOST" + CODEXCLI_INSTANCE='env' + codexcli_select_home_host + assert_ne "$first" "$CODEXCLI_HOME_HOST" + cleanup_case +} + +test_env_instance_matches_cli_instance_path() { + setup_case + CODEXCLI_INSTANCE='review' + codexcli_select_home_host + cli_path="$CODEXCLI_HOME_HOST" + CODEXCLI_INSTANCE='review' + codexcli_select_home_host + env_path="$CODEXCLI_HOME_HOST" + assert_eq "$env_path" "$cli_path" + cleanup_case +} + +test_instance_home_seeded_from_default() { + setup_case + mkdir -p "$HOME/.local/share/codexcli/codex-home/rules" + printf 'policy\n' >"$HOME/.local/share/codexcli/codex-home/rules/default.rules" + CODEXCLI_INSTANCE='review' + codexcli_select_home_host + codexcli_prepare_home_host + [ -f "$CODEXCLI_HOME_HOST/rules/default.rules" ] || fail "instance home was not seeded" + [ -d "$HOME/.local/share/codexcli/codex-home" ] || fail "default home was removed" + cleanup_case +} + +test_doctor_home_fields() { + setup_case + CODEXCLI_INSTANCE='review' + codexcli_select_home_host + assert_eq "$CODEXCLI_HOME_SOURCE" "instance-isolated" + [ -n "$CODEXCLI_HOME_HOST" ] || fail "doctor home path would be empty" + cleanup_case +} + +test_default_home +test_instance_home +test_two_instances_differ +test_env_instance_matches_cli_instance_path +test_instance_home_seeded_from_default +test_doctor_home_fields +printf 'ok - runtime home selection\n'