Files
hassio-addons/claude_desktop/rootfs/etc/cont-init.d/81-codex_cli.sh
2026-07-28 07:54:51 +02:00

329 lines
12 KiB
Bash
Executable File

#!/usr/bin/with-contenv bashio
# shellcheck shell=bash
set -e
set -o pipefail
# OpenAI Codex CLI, installed on demand rather than baked into the image: the Linux release
# binary is large and the option is off by default. Runs before 82-claude_tools.sh so the binary
# exists when that script registers the `codex` MCP server.
#
# The install prefix is /data/codex, NOT $HOME/.codex/bin: /data is persistent regardless of the
# configurable data_location, and the managed MCP merge treats commands under $HOME as
# user-installed. Codex state (auth.json, config.toml) remains in the runtime user's home.
CODEX_ROOT="/data/codex"
CODEX_PREFIX="${CODEX_ROOT}/bin"
CODEX_BIN="${CODEX_PREFIX}/codex"
CODEX_REAL="${CODEX_PREFIX}/codex-real"
CODEX_STAMP="${CODEX_PREFIX}/.version"
CODEX_LINK="/usr/local/bin/codex"
CODEX_RELEASE_API="https://api.github.com/repos/openai/codex/releases/latest"
RUNTIME_HOME="$(getent passwd abc | cut -d: -f6)"
if [ -z "$RUNTIME_HOME" ]; then
bashio::log.warning "Unable to resolve the abc runtime home; using /data/data"
RUNTIME_HOME="/data/data"
fi
run_as_runtime_user() {
s6-setuidgid abc env HOME="$RUNTIME_HOME" CODEX_HOME="$RUNTIME_HOME/.codex" "$@"
}
if ! bashio::config.true 'install_codex_cli'; then
# Non-destructive: preserve the binary and completed ChatGPT sign-in for a later re-enable.
# 82-claude_tools.sh removes only the MCP registration and managed guidance.
bashio::log.info "Codex CLI disabled"
exit 0
fi
case "$(uname -m)" in
x86_64) CODEX_TARGET="x86_64-unknown-linux-musl" ;;
aarch64 | arm64) CODEX_TARGET="aarch64-unknown-linux-musl" ;;
*)
bashio::log.warning "Codex CLI has no release binary for $(uname -m); skipping"
exit 0
;;
esac
CODEX_ASSET="codex-${CODEX_TARGET}.tar.gz"
mkdir -p "$CODEX_PREFIX"
# Migrate the PR's earlier direct-binary layout to the enforced wrapper layout without another
# download. The real binary is kept separately; `codex` becomes a small launcher that always
# forces ChatGPT subscription authentication and removes any inherited API key.
if [ ! -x "$CODEX_REAL" ] \
&& [ -x "$CODEX_BIN" ] \
&& run_as_runtime_user "$CODEX_BIN" --version > /dev/null 2>&1; then
mv -f "$CODEX_BIN" "$CODEX_REAL"
fi
# Resolve the latest stable release and its GitHub-published SHA-256 digest on every boot. This
# follows upstream updates without pinning a version, while downloading the large asset only when
# the installed version changes. A metadata outage never replaces or removes a working binary.
codex_tmp="$(mktemp -d -p "$CODEX_ROOT")"
# mktemp always creates 0700 root:root here, but the candidate binary is validated by running it
# as the abc runtime user, which cannot traverse a root-only directory — that made every install
# fail at the --version step with "unable to exec: Permission denied" (exit 126) and report
# "Codex is unavailable this boot". Make the staging directory traversable. Nothing secret is
# staged here: it holds the public release archive and the extracted binary, both of which are
# world-readable upstream artifacts, and cleanup() removes the directory on exit.
chmod 0755 "$codex_tmp"
cleanup() {
rm -rf "$codex_tmp"
}
trap cleanup EXIT
release_metadata="${codex_tmp}/release.json"
release_info=""
if curl -fsSL --retry 3 --retry-delay 2 --connect-timeout 10 --max-time 30 \
-o "$release_metadata" "$CODEX_RELEASE_API"; then
release_info="$(
CODEX_ASSET="$CODEX_ASSET" python3 - "$release_metadata" <<'PY' 2> /dev/null || true
import json
import os
import re
import sys
from pathlib import Path
metadata = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
tag = metadata.get("tag_name", "")
if not isinstance(tag, str) or not tag.startswith("rust-v"):
raise SystemExit("unexpected release tag")
version = tag.removeprefix("rust-v")
if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:-(?:alpha|beta)(?:\.[0-9]+){0,2})?", version):
raise SystemExit("unexpected release version")
asset_name = os.environ["CODEX_ASSET"]
asset = next(
(
item
for item in metadata.get("assets", [])
if isinstance(item, dict) and item.get("name") == asset_name
),
None,
)
if asset is None:
raise SystemExit("release asset missing")
digest = asset.get("digest", "")
if not isinstance(digest, str) or not re.fullmatch(r"sha256:[0-9a-fA-F]{64}", digest):
raise SystemExit("release asset has no valid SHA-256 digest")
url = asset.get("browser_download_url", "")
if not isinstance(url, str) or not url.startswith("https://github.com/openai/codex/releases/download/"):
raise SystemExit("unexpected release asset URL")
print(f"{version}\t{digest.removeprefix('sha256:').lower()}\t{url}")
PY
)"
fi
if [ -z "$release_info" ]; then
if [ -x "$CODEX_REAL" ] && run_as_runtime_user "$CODEX_REAL" --version > /dev/null 2>&1; then
bashio::log.warning "Unable to resolve the latest verified Codex release; keeping the existing install"
else
bashio::log.warning "Unable to resolve the latest verified Codex release; Codex is unavailable this boot"
exit 0
fi
else
IFS=$'\t' read -r CODEX_WANTED CODEX_SHA256 CODEX_URL <<< "$release_info"
if [ -x "$CODEX_REAL" ] \
&& [ "$(cat "$CODEX_STAMP" 2> /dev/null || true)" = "$CODEX_WANTED" ] \
&& run_as_runtime_user "$CODEX_REAL" --version > /dev/null 2>&1; then
bashio::log.info "Codex CLI ${CODEX_WANTED} already installed (latest stable)"
else
bashio::log.info "Installing latest stable Codex CLI ${CODEX_WANTED} (${CODEX_TARGET}); this is a large one-time download"
archive="${codex_tmp}/${CODEX_ASSET}"
extracted="${codex_tmp}/codex-${CODEX_TARGET}"
# Fail open for add-on startup but fail closed for the candidate binary: its official
# release digest must match before extraction or execution, and replacement happens only
# after the staged binary successfully runs.
if curl -fsSL --retry 3 --retry-delay 2 --connect-timeout 10 --max-time 600 \
-o "$archive" "$CODEX_URL" \
&& printf '%s %s\n' "$CODEX_SHA256" "$archive" | sha256sum -c - > /dev/null \
&& tar -xzf "$archive" -C "$codex_tmp" \
&& [ -f "$extracted" ] \
&& chmod 0755 "$extracted" \
&& run_as_runtime_user "$extracted" --version > /dev/null 2>&1 \
&& mv -f "$extracted" "$CODEX_REAL"; then
printf '%s' "$CODEX_WANTED" > "$CODEX_STAMP"
bashio::log.info "Codex CLI installed: $("$CODEX_REAL" --version 2> /dev/null || echo unknown)"
elif [ -x "$CODEX_REAL" ]; then
bashio::log.warning "Verified Codex ${CODEX_WANTED} installation failed; keeping the existing install"
else
bashio::log.warning "Verified Codex ${CODEX_WANTED} installation failed; Codex is unavailable this boot"
fi
fi
fi
if [ ! -x "$CODEX_REAL" ]; then
exit 0
fi
# Every Codex entry point, including the MCP server launched by Claude, goes through this wrapper.
# This is an execution-time guarantee in addition to the managed config below: API-key billing
# cannot be selected even if an API key is present in the surrounding environment. Caller-provided
# overrides for the two authentication guards are stripped before the forced root-level overrides
# are inserted; root-level -c flags must precede Codex subcommands such as `mcp-server`.
{
printf '#!/usr/bin/env bash\n'
printf 'CODEX_REAL=%q\n' "$CODEX_REAL"
cat <<'SH'
RUNTIME_HOME="$(getent passwd abc | cut -d: -f6)"
if [ -z "$RUNTIME_HOME" ]; then
echo "codex: unable to resolve the abc runtime home" >&2
exit 1
fi
is_managed_override() {
local assignment="$1"
local key="${assignment%%=*}"
key="${key//[[:space:]]/}"
case "$key" in
forced_login_method | cli_auth_credentials_store) return 0 ;;
*) return 1 ;;
esac
}
filtered_args=()
while [ "$#" -gt 0 ]; do
case "$1" in
-c | --config)
if [ "$#" -lt 2 ]; then
filtered_args+=("$1")
shift
continue
fi
if is_managed_override "$2"; then
shift 2
continue
fi
filtered_args+=("$1" "$2")
shift 2
;;
--config=*)
assignment="${1#--config=}"
if ! is_managed_override "$assignment"; then
filtered_args+=("$1")
fi
shift
;;
-c*)
assignment="${1#-c}"
if ! is_managed_override "$assignment"; then
filtered_args+=("$1")
fi
shift
;;
*)
filtered_args+=("$1")
shift
;;
esac
done
forced_args=(
-c 'forced_login_method="chatgpt"'
-c 'cli_auth_credentials_store="file"'
)
if [ "$(id -u)" -eq 0 ]; then
exec s6-setuidgid abc env -u OPENAI_API_KEY \
HOME="$RUNTIME_HOME" CODEX_HOME="$RUNTIME_HOME/.codex" \
"$CODEX_REAL" "${forced_args[@]}" "${filtered_args[@]}"
fi
unset OPENAI_API_KEY
export HOME="$RUNTIME_HOME"
export CODEX_HOME="$RUNTIME_HOME/.codex"
exec "$CODEX_REAL" "${forced_args[@]}" "${filtered_args[@]}"
SH
} > "$CODEX_BIN"
chmod 0755 "$CODEX_BIN"
chown -R -- "$(id -u abc):$(id -g abc)" "$CODEX_ROOT" \
|| bashio::log.warning "Unable to set ownership on ${CODEX_ROOT}"
ln -sfn "$CODEX_BIN" "$CODEX_LINK"
# Manage the root-level defaults used by terminal Codex and by `codex mcp-server`.
#
# `forced_login_method = "chatgpt"` makes subscription authentication the only permitted login
# method, so an inherited OPENAI_API_KEY cannot silently switch this integration to API billing.
# File storage is explicit because the container has no supported OS keyring.
#
# The managed block must be first: a bare TOML key after a [table] header belongs to that table.
# Existing top-level definitions of the managed keys are removed before insertion; retaining them
# would create duplicate keys and make the entire Codex configuration invalid.
CODEX_SANDBOX_MODE="$(bashio::config 'codex_sandbox_mode' 'danger-full-access')"
run_as_runtime_user mkdir -p "$RUNTIME_HOME/.codex"
CODEX_SANDBOX_MODE="$CODEX_SANDBOX_MODE" RUNTIME_HOME="$RUNTIME_HOME" \
run_as_runtime_user python3 - <<'PY' \
|| bashio::log.warning "Unable to update the managed Codex configuration block"
import os
import re
import tomllib
from pathlib import Path
BEGIN = "# BEGIN managed by claude_desktop addon"
END = "# END managed by claude_desktop addon"
MANAGED_KEYS = {
"sandbox_mode",
"approval_policy",
"forced_login_method",
"cli_auth_credentials_store",
}
block = "\n".join(
[
BEGIN,
"# Managed defaults for terminal and MCP-driven Codex runs.",
f'sandbox_mode = "{os.environ["CODEX_SANDBOX_MODE"]}"',
'approval_policy = "never"',
"# Require ChatGPT subscription OAuth; do not fall back to API-key billing.",
'forced_login_method = "chatgpt"',
"# This container has no supported OS keyring; keep OAuth credentials in auth.json.",
'cli_auth_credentials_store = "file"',
END,
]
)
path = Path(os.environ["RUNTIME_HOME"]) / ".codex" / "config.toml"
original = path.read_text(encoding="utf-8") if path.exists() else ""
rest = re.sub(
rf"{re.escape(BEGIN)}.*?{re.escape(END)}\n?",
"",
original,
flags=re.DOTALL,
)
table_header = re.compile(r"^\s*\[\[?.+?\]\]?\s*(?:#.*)?$")
assignment = re.compile(
r'''^\s*(?P<key>[A-Za-z0-9_-]+|"[^"]+"|'[^']+')\s*='''
)
kept = []
at_top_level = True
for line in rest.splitlines(keepends=True):
if at_top_level and table_header.match(line):
at_top_level = False
match = assignment.match(line) if at_top_level else None
if match:
key = match.group("key")
if key[:1] in {'"', "'"}:
key = key[1:-1]
if key in MANAGED_KEYS:
continue
kept.append(line)
remainder = "".join(kept).lstrip("\n")
new = block + "\n" + (("\n" + remainder) if remainder else "")
tomllib.loads(new)
if new != original:
path.write_text(new, encoding="utf-8")
path.chmod(0o600)
PY
if [ -f "$RUNTIME_HOME/.codex/auth.json" ]; then
bashio::log.info "Codex CLI is signed in with stored ChatGPT credentials"
else
bashio::log.info "Codex CLI is not signed in yet; run 'codex-login' to activate your ChatGPT subscription"
fi