#!/bin/bash # NOTE: set -e is intentionally omitted. A failing usermod/groupmod must not # kill the entire entrypoint — SSH setup, git config, and the final exec # must still run so the container is usable even if remapping fails. # # NOTE: /home/claude is the mount point of the named volume # triple-c-home-{projectId}, so the *image's* copy of that directory is # seed-only: after a project's first start it is masked permanently. Anything # this script writes under /home/claude on **every** start does reach existing # projects (that is why the CLAUDE.md, git config and Mission Control skill # copies are written here rather than baked into the image). Anything added to # /home/claude in the Dockerfile reaches new projects only, forever. Put # upgradable content in /usr/local/bin or /opt, or seed it from here. # See "Container Lifecycle" in the repo's CLAUDE.md. # ── UID/GID remapping ────────────────────────────────────────────────────── # Match the container's claude user to the host user's UID/GID so that # bind-mounted files (project dir, docker socket) have correct ownership. remap_uid_gid() { local target_uid="${HOST_UID}" local target_gid="${HOST_GID}" local current_uid local current_gid current_uid=$(id -u claude 2>/dev/null) || { echo "entrypoint: claude user not found"; return 1; } current_gid=$(id -g claude 2>/dev/null) || { echo "entrypoint: claude group not found"; return 1; } # ── GID remapping ── if [ -n "$target_gid" ] && [ "$target_gid" != "$current_gid" ]; then # If another group already holds the target GID, move it out of the way local blocking_group blocking_group=$(getent group "$target_gid" 2>/dev/null | cut -d: -f1) if [ -n "$blocking_group" ] && [ "$blocking_group" != "claude" ]; then echo "entrypoint: moving group '$blocking_group' from GID $target_gid to 65533" groupmod -g 65533 "$blocking_group" || echo "entrypoint: warning — failed to relocate group '$blocking_group'" fi groupmod -g "$target_gid" claude \ && echo "entrypoint: claude GID -> $target_gid" \ || echo "entrypoint: warning — groupmod -g $target_gid claude failed" fi # ── UID remapping ── if [ -n "$target_uid" ] && [ "$target_uid" != "$current_uid" ]; then # If another user already holds the target UID, move it out of the way local blocking_user blocking_user=$(getent passwd "$target_uid" 2>/dev/null | cut -d: -f1) if [ -n "$blocking_user" ] && [ "$blocking_user" != "claude" ]; then echo "entrypoint: moving user '$blocking_user' from UID $target_uid to 65533" usermod -u 65533 "$blocking_user" || echo "entrypoint: warning — failed to relocate user '$blocking_user'" fi usermod -u "$target_uid" claude \ && echo "entrypoint: claude UID -> $target_uid" \ || echo "entrypoint: warning — usermod -u $target_uid claude failed" fi } remap_uid_gid # Fix ownership of home directory after UID/GID change chown -R claude:claude /home/claude # ── Corporate CA certificates ─────────────────────────────────────────────── # The host's CA material is bind-mounted read-only at /tmp/.host-ca. Triple-C # mounts a *directory* as-is and a *single file* as /tmp/.host-ca/.crt, # so this only ever has to deal with a directory (the file branch below is # defensive). # # Runs before everything that touches the network — the git credential helper, # ssh-keyscan, and especially the `claude update` at the bottom of this file, # which is itself an HTTPS call that fails behind a TLS-terminating proxy # without this. # # Two things are easy to get wrong here: # 1. `update-ca-certificates` globs /usr/local/share/ca-certificates/*.crt # case-sensitively. A `.pem` that is merely copied in is ignored in total # silence, so certificates are *renamed*, not copied. # 2. Chrome/Chromium read neither /etc/ssl nor $SSL_CERT_FILE; they have # their own NSS database at ~/.pki/nssdb, seeded below with certutil. # # NODE_EXTRA_CA_CERTS / REQUESTS_CA_BUNDLE / SSL_CERT_FILE are deliberately NOT # exported here. Every terminal is a separate `docker exec`, which inherits the # container's configured env and sees nothing this script exported — the same # reason $BROWSER had to become an image-level ENV. Triple-C sets them on the # container at creation time instead. (They are forwarded into the cron # environment file further down, because cron jobs start from a bare env.) CA_SRC="/tmp/.host-ca" CA_STORE="/usr/local/share/ca-certificates" CA_PREFIX="triple-c-" CA_BUNDLE="/etc/ssl/certs/ca-certificates.crt" CA_STAMP="/var/lib/triple-c/ca.stamp" CA_NSSDB="/home/claude/.pki/nssdb" # Mirror of `container_cert_name()` in app/src-tauri/src/docker/ca_certs.rs. # The two must agree; the Rust side has the unit tests. ca_normalise_name() { local name stem name=$(printf '%s' "$1" | tr -c 'A-Za-z0-9._-' '_') while [ "${name#.}" != "$name" ]; do name="${name#.}"; done stem="${name%.*}" [ -z "$stem" ] && stem="corporate-ca" printf '%s.crt' "$stem" } ca_source_files() { if [ -d "$CA_SRC" ]; then find "$CA_SRC" -maxdepth 1 -type f \ \( -iname '*.crt' -o -iname '*.pem' -o -iname '*.cer' \ -o -iname '*.cert' -o -iname '*.ca-bundle' \) 2>/dev/null | sort elif [ -f "$CA_SRC" ]; then printf '%s\n' "$CA_SRC" fi } # Seed Chrome/Chromium's NSS database. Tolerant by design: a missing certutil # or a broken profile must warn, never fail the container start. # ~/.pki lives in the home volume, so this persists once done; the system store # lives in the writable layer and is re-applied on every start. ca_seed_nssdb() { if ! command -v certutil >/dev/null 2>&1; then echo "entrypoint: warning — certutil not found (install libnss3-tools); Chrome/Chromium in this container will not trust the corporate CA" return 0 fi su -s /bin/bash claude -c ' db="$HOME/.pki/nssdb" mkdir -p "$db" || exit 1 if [ ! -f "$db/cert9.db" ]; then certutil -d "sql:$db" -N --empty-password >/dev/null 2>&1 || exit 1 fi for f in /usr/local/share/ca-certificates/triple-c-*.crt; do [ -f "$f" ] || continue nick="triple-c:$(basename "$f" .crt)" # Delete first so re-running replaces rather than duplicates. certutil -d "sql:$db" -D -n "$nick" >/dev/null 2>&1 certutil -d "sql:$db" -A -t "C,," -n "$nick" -i "$f" >/dev/null 2>&1 \ || echo "entrypoint: warning — certutil could not add $nick" done ' && echo "entrypoint: seeded Chrome/Chromium NSS database with the corporate CA" \ || echo "entrypoint: warning — NSS database seeding failed (continuing)" } install_corporate_ca() { local files fp stamp f base name count installed files=$(ca_source_files) if [ -z "$files" ]; then # Nothing configured — but /usr/local/share is in the writable layer and # `docker commit` bakes it into the project's snapshot image, so a cert # installed by a previous configuration would ride that snapshot into # every future container. Turning the setting off has to actively undo. if ls "$CA_STORE/$CA_PREFIX"*.crt >/dev/null 2>&1; then echo "entrypoint: removing previously installed corporate CA certificates" rm -f "$CA_STORE/$CA_PREFIX"*.crt update-ca-certificates --fresh >/dev/null 2>&1 \ || echo "entrypoint: warning — update-ca-certificates failed while removing certificates" rm -f "$CA_STAMP" fi if [ -e "$CA_SRC" ]; then echo "entrypoint: warning — $CA_SRC holds no certificate files" fi return 0 fi # Idempotent and cheap: the certs are already installed on a plain restart # (the writable layer survives stop/start), so hash the sources and skip the # work when nothing has moved. The NSS database is checked separately # because it lives in the home volume and can be wiped independently. fp=$(printf '%s\n' "$files" | xargs -d '\n' -r sha256sum 2>/dev/null | sha256sum | cut -d' ' -f1) stamp=$(cat "$CA_STAMP" 2>/dev/null) if [ -n "$fp" ] && [ "$fp" = "$stamp" ] && [ -s "$CA_BUNDLE" ]; then if [ -f "$CA_NSSDB/cert9.db" ]; then echo "entrypoint: corporate CA certificates already installed" return 0 fi ca_seed_nssdb return 0 fi mkdir -p "$CA_STORE" "$(dirname "$CA_STAMP")" rm -f "$CA_STORE/$CA_PREFIX"*.crt installed=0 while IFS= read -r f; do [ -n "$f" ] || continue base=$(basename "$f") name="$CA_PREFIX$(ca_normalise_name "$base")" count=$(grep -c -- '-----BEGIN CERTIFICATE-----' "$f" 2>/dev/null || true) [ -n "$count" ] || count=0 if [ "$count" -gt 1 ]; then # A corporate trust chain is usually delivered as one PEM holding # root + intermediates. update-ca-certificates handles exactly one # certificate per file, so split it. awk -v out="$CA_STORE/${name%.crt}" ' /-----BEGIN CERTIFICATE-----/ { n++; f = out "-" n ".crt" } n > 0 { print > f } ' "$f" && installed=$((installed + count)) elif [ "$count" -eq 1 ]; then cp -f "$f" "$CA_STORE/$name" && installed=$((installed + 1)) else echo "entrypoint: warning — $f holds no PEM certificate (DER is not supported), skipping" fi done <<< "$files" chmod 644 "$CA_STORE/$CA_PREFIX"*.crt 2>/dev/null if [ "$installed" -eq 0 ]; then echo "entrypoint: warning — no usable certificates found under $CA_SRC" return 0 fi if update-ca-certificates >/dev/null 2>&1; then echo "entrypoint: installed $installed corporate CA certificate(s) into the system trust store" printf '%s' "$fp" > "$CA_STAMP" else echo "entrypoint: warning — update-ca-certificates failed; corporate certificates may not be trusted" fi ca_seed_nssdb } install_corporate_ca # ── SSH key setup ────────────────────────────────────────────────────────── # Host SSH dir is mounted read-only at /tmp/.host-ssh. # Copy to /home/claude/.ssh so we can fix permissions. if [ -d /tmp/.host-ssh ]; then rm -rf /home/claude/.ssh cp -a /tmp/.host-ssh /home/claude/.ssh chown -R claude:claude /home/claude/.ssh chmod 700 /home/claude/.ssh find /home/claude/.ssh -type f -name "id_*" ! -name "*.pub" -exec chmod 600 {} \; find /home/claude/.ssh -type f -name "*.pub" -exec chmod 644 {} \; if [ -f /home/claude/.ssh/known_hosts ]; then chmod 644 /home/claude/.ssh/known_hosts fi if [ -f /home/claude/.ssh/config ]; then chmod 600 /home/claude/.ssh/config fi fi # Append common host keys (avoid duplicates) su -s /bin/bash claude -c ' mkdir -p /home/claude/.ssh ssh-keyscan -t ed25519,rsa github.com gitlab.com bitbucket.org >> /home/claude/.ssh/known_hosts 2>/dev/null || true sort -u -o /home/claude/.ssh/known_hosts /home/claude/.ssh/known_hosts ' # ── AWS config setup ────────────────────────────────────────────────────────── # Host AWS dir is mounted read-only at /tmp/.host-aws. # Copy to /home/claude/.aws so AWS CLI can write to sso/cache and cli/cache. if [ -d /tmp/.host-aws ]; then rm -rf /home/claude/.aws cp -a /tmp/.host-aws /home/claude/.aws chown -R claude:claude /home/claude/.aws chmod 700 /home/claude/.aws # Ensure writable cache directories exist mkdir -p /home/claude/.aws/sso/cache /home/claude/.aws/cli/cache chown -R claude:claude /home/claude/.aws/sso /home/claude/.aws/cli # Inline sso_session properties into profile sections so AWS SDKs that don't # support the sso_session indirection format can resolve sso_region, etc. if [ -f /home/claude/.aws/config ]; then python3 -c ' import configparser, sys c = configparser.ConfigParser() c.read(sys.argv[1]) for sec in c.sections(): if not sec.startswith("profile ") and sec != "default": continue session = c.get(sec, "sso_session", fallback=None) if not session or c.has_option(sec, "sso_start_url"): continue ss = f"sso-session {session}" if not c.has_section(ss): continue for key in ("sso_start_url", "sso_region", "sso_registration_scopes"): val = c.get(ss, key, fallback=None) if val: c.set(sec, key, val) with open(sys.argv[1], "w") as f: c.write(f) ' /home/claude/.aws/config 2>/dev/null || true fi fi # ── Git credential helper (for HTTPS token) ───────────────────────────────── if [ -n "$GIT_TOKEN" ]; then CRED_FILE="/home/claude/.git-credentials" : > "$CRED_FILE" chmod 600 "$CRED_FILE" chown claude:claude "$CRED_FILE" echo "https://oauth2:${GIT_TOKEN}@github.com" >> "$CRED_FILE" echo "https://oauth2:${GIT_TOKEN}@gitlab.com" >> "$CRED_FILE" echo "https://oauth2:${GIT_TOKEN}@bitbucket.org" >> "$CRED_FILE" git config --file /home/claude/.gitconfig credential.helper "store --file=$CRED_FILE" unset GIT_TOKEN fi # ── Git user config ────────────────────────────────────────────────────────── if [ -n "$GIT_USER_NAME" ]; then git config --file /home/claude/.gitconfig user.name "$GIT_USER_NAME" fi if [ -n "$GIT_USER_EMAIL" ]; then git config --file /home/claude/.gitconfig user.email "$GIT_USER_EMAIL" fi chown claude:claude /home/claude/.gitconfig 2>/dev/null || true # ── Claude instructions ────────────────────────────────────────────────────── if [ -n "$CLAUDE_INSTRUCTIONS" ]; then mkdir -p /home/claude/.claude printf '%s\n' "$CLAUDE_INSTRUCTIONS" > /home/claude/.claude/CLAUDE.md chown claude:claude /home/claude/.claude/CLAUDE.md unset CLAUDE_INSTRUCTIONS fi # ── Mission Control setup ─────────────────────────────────────────────────── if [ "$MISSION_CONTROL_ENABLED" = "1" ]; then MC_HOME="/home/claude/mission-control" MC_LINK="/workspace/mission-control" if [ ! -d "$MC_HOME" ]; then echo "entrypoint: installing mission-control..." cp -r /opt/mission-control "$MC_HOME" chown -R claude:claude "$MC_HOME" else echo "entrypoint: mission-control already present, skipping install" fi # Symlink into workspace so Claude sees it at /workspace/mission-control ln -sfn "$MC_HOME" "$MC_LINK" chown -h claude:claude "$MC_LINK" # Install skills to ~/.claude/skills/ so Claude Code discovers them automatically if [ -d "$MC_HOME/.claude/skills" ]; then mkdir -p /home/claude/.claude/skills cp -r "$MC_HOME/.claude/skills/"* /home/claude/.claude/skills/ 2>/dev/null chown -R claude:claude /home/claude/.claude/skills echo "entrypoint: mission-control skills installed to ~/.claude/skills/" fi unset MISSION_CONTROL_ENABLED fi # ── Claude Code settings ──────────────────────────────────────────────────── # Merge Claude Code settings into ~/.claude/settings.json (preserves existing # keys). Creates the file if it doesn't exist. These control TUI mode, effort # level, focus mode, thinking summaries, and other CLI behavior. if [ -n "$CLAUDE_CODE_SETTINGS_JSON" ]; then SETTINGS_FILE="/home/claude/.claude/settings.json" mkdir -p /home/claude/.claude if [ -f "$SETTINGS_FILE" ]; then # Merge: existing settings + new settings (new keys override on conflict) MERGED=$(jq -s '.[0] * .[1]' "$SETTINGS_FILE" <(printf '%s' "$CLAUDE_CODE_SETTINGS_JSON") 2>/dev/null) if [ -n "$MERGED" ]; then printf '%s\n' "$MERGED" > "$SETTINGS_FILE" else echo "entrypoint: warning — failed to merge Claude Code settings into $SETTINGS_FILE" fi else printf '%s\n' "$CLAUDE_CODE_SETTINGS_JSON" > "$SETTINGS_FILE" fi chown claude:claude "$SETTINGS_FILE" chmod 600 "$SETTINGS_FILE" unset CLAUDE_CODE_SETTINGS_JSON fi # ── AWS SSO auth refresh command ────────────────────────────────────────────── # When set (Bedrock + profile/SSO auth), inject awsAuthRefresh into # ~/.claude.json so Claude Code calls triple-c-sso-refresh when AWS credentials # expire mid-session. When NOT set, strip any awsAuthRefresh left behind by a # previous Bedrock-profile session — ~/.claude.json lives in the persisted home # volume, so without this the container keeps trying to run the SSO refresh even # after switching to a non-SSO backend (Anthropic/Ollama) or to static creds. CLAUDE_JSON="/home/claude/.claude.json" if [ -n "$AWS_SSO_AUTH_REFRESH_CMD" ]; then if [ -f "$CLAUDE_JSON" ]; then MERGED=$(jq --arg cmd "$AWS_SSO_AUTH_REFRESH_CMD" '.awsAuthRefresh = $cmd' "$CLAUDE_JSON" 2>/dev/null) if [ -n "$MERGED" ]; then printf '%s\n' "$MERGED" > "$CLAUDE_JSON" fi else printf '{"awsAuthRefresh":"%s"}\n' "$AWS_SSO_AUTH_REFRESH_CMD" > "$CLAUDE_JSON" fi chown claude:claude "$CLAUDE_JSON" chmod 600 "$CLAUDE_JSON" unset AWS_SSO_AUTH_REFRESH_CMD elif [ -f "$CLAUDE_JSON" ] && grep -q '"awsAuthRefresh"' "$CLAUDE_JSON" 2>/dev/null; then # Only rewrite when the key is actually present, to avoid a needless jq # reformat of ~/.claude.json on every start of a non-SSO backend. MERGED=$(jq 'del(.awsAuthRefresh)' "$CLAUDE_JSON" 2>/dev/null) if [ -n "$MERGED" ]; then printf '%s\n' "$MERGED" > "$CLAUDE_JSON" chown claude:claude "$CLAUDE_JSON" chmod 600 "$CLAUDE_JSON" fi fi # ── Docker socket permissions ──────────────────────────────────────────────── if [ -S /var/run/docker.sock ]; then DOCKER_GID=$(stat -c '%g' /var/run/docker.sock) if ! getent group "$DOCKER_GID" > /dev/null 2>&1; then groupadd -g "$DOCKER_GID" docker-host fi DOCKER_GROUP=$(getent group "$DOCKER_GID" | cut -d: -f1) usermod -aG "$DOCKER_GROUP" claude fi # ── Timezone setup ─────────────────────────────────────────────────────────── if [ -n "${TZ:-}" ]; then if [ -f "/usr/share/zoneinfo/$TZ" ]; then ln -sf "/usr/share/zoneinfo/$TZ" /etc/localtime echo "$TZ" > /etc/timezone echo "entrypoint: timezone set to $TZ" else echo "entrypoint: warning — timezone '$TZ' not found in /usr/share/zoneinfo" fi fi # ── Browser / URL relay ────────────────────────────────────────────────────── # Tools that open a browser (gh, aws sso login, gcloud, python webbrowser, ...) # consult $BROWSER first. Point it at the relay shim, which forwards the URL to # the host's browser over the terminal. The image already sets this as an ENV — # that is what `docker exec` terminal sessions inherit — but exporting it here # means the entrypoint's own children see it too, and (below) that it is # captured into the cron environment file for scheduled tasks. Under cron there # is no terminal, so the shim degrades to printing the URL into the task log. if [ -x /usr/local/bin/triple-c-open ]; then export BROWSER=/usr/local/bin/triple-c-open fi # ── Scheduler setup ───────────────────────────────────────────────────────── SCHEDULER_DIR="/home/claude/.claude/scheduler" mkdir -p "$SCHEDULER_DIR/tasks" "$SCHEDULER_DIR/logs" "$SCHEDULER_DIR/notifications" chown -R claude:claude "$SCHEDULER_DIR" # Start cron daemon (runs as root, executes jobs per user crontab) cron # Save environment variables for cron jobs (cron runs with a minimal env) # # HOME is deliberately NOT captured here. This entrypoint runs as root, so the # snapshot would record HOME=/root — and the task runner sources this file with # `set -a`, which would overwrite the HOME cron gives the job. Claude Code then # looks for its OAuth credential at /root/.claude/.credentials.json instead of # /home/claude/.claude/.credentials.json and every scheduled task dies with # "Not logged in · Please run /login". Cron still needs a HOME, so it is written # explicitly below with the value the `claude` user actually has. ENV_FILE="$SCHEDULER_DIR/.env" : > "$ENV_FILE" env | while IFS='=' read -r key value; do case "$key" in ANTHROPIC_*|AWS_*|CLAUDE_CODE_*|TRIPLE_C_PERMISSION_MODE|PATH|LANG|TZ|COLORTERM|BROWSER|NODE_EXTRA_CA_CERTS|REQUESTS_CA_BUNDLE|SSL_CERT_FILE) # Escape single quotes in value and write as KEY='VALUE' escaped_value=$(printf '%s' "$value" | sed "s/'/'\\\\''/g") printf "%s='%s'\n" "$key" "$escaped_value" >> "$ENV_FILE" ;; esac done printf "HOME='/home/claude'\n" >> "$ENV_FILE" chown claude:claude "$ENV_FILE" chmod 600 "$ENV_FILE" # Restore crontab from persisted task JSON files (survives container recreation) if ls "$SCHEDULER_DIR/tasks/"*.json >/dev/null 2>&1; then CRON_TMP=$(mktemp) echo "# Triple-C scheduled tasks — managed by triple-c-scheduler" > "$CRON_TMP" echo "# Do not edit manually; changes will be overwritten." >> "$CRON_TMP" echo "" >> "$CRON_TMP" for task_file in "$SCHEDULER_DIR/tasks/"*.json; do [ -f "$task_file" ] || continue enabled=$(jq -r '.enabled' "$task_file") [ "$enabled" = "true" ] || continue schedule=$(jq -r '.schedule' "$task_file") id=$(jq -r '.id' "$task_file") echo "$schedule /usr/local/bin/triple-c-task-runner $id" >> "$CRON_TMP" done crontab -u claude "$CRON_TMP" 2>/dev/null || true rm -f "$CRON_TMP" echo "entrypoint: restored crontab from persisted tasks" fi # ── Claude Code self-update ────────────────────────────────────────────────── # Update the Claude Code CLI to the latest version on container start, before # any terminal session launches `claude`. Runs as the claude user (the CLI is # installed under /home/claude/.claude/bin). Non-fatal and time-bounded so a # slow or offline network never blocks container readiness. echo "entrypoint: checking for Claude Code updates..." timeout 120 su -s /bin/bash claude -c 'export PATH="/home/claude/.claude/bin:/home/claude/.local/bin:$PATH"; claude update' \ && echo "entrypoint: Claude Code is up to date" \ || echo "entrypoint: warning — Claude Code update skipped or failed (continuing)" # ── Stay alive as claude ───────────────────────────────────────────────────── echo "Triple-C container ready." exec su -s /bin/bash claude -c "exec sleep infinity"