Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion Containerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
31 changes: 19 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<project-key>/<instance>/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`.
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
18 changes: 14 additions & 4 deletions docs/runtime-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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/<project-key>/<instance>/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.
67 changes: 63 additions & 4 deletions scripts/lib-codexcli-runtime.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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")"
Expand All @@ -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() {
Expand Down Expand Up @@ -99,6 +103,51 @@ codexcli_project_env_files() {
printf '%s' "${found:-<none>}"
}

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
Expand Down Expand Up @@ -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:-<default>}"
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
Expand Down Expand Up @@ -269,13 +320,17 @@ 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"
-e "CODEXCLI_DOCTOR_RUNTIME_IMAGE=$CODEXCLI_RUNTIME_IMAGE"
-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")
Expand All @@ -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
Expand Down
101 changes: 101 additions & 0 deletions tests/runtime-home-selection.sh
Original file line number Diff line number Diff line change
@@ -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'
Loading