Files
Triple-C/container/entrypoint.sh
Claude 7a5823cb2b
Build App / compute-version (pull_request) Successful in 6s
Build App / build-macos (pull_request) Successful in 2m30s
Build App / build-windows (pull_request) Successful in 5m16s
Build Container / build-container (pull_request) Successful in 10m15s
Build App / build-linux (pull_request) Successful in 6m35s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Inject the corporate CA certificate into containers
Behind a TLS-terminating corporate proxy every HTTPS call inside a container
fails — npm, pip, git, curl, the browser-view pane, and Claude Code's own API
requests. There was no mechanism at all: installing the certificate by hand
inside a container is lost on Reset and had to be repeated per project.

A global CA path in AppSettings with a per-project override on Project, taking
either a single certificate file or a directory. It is bind-mounted read-only
at /tmp/.host-ca (mirroring /tmp/.host-ssh and /tmp/.host-aws) and applied by
entrypoint.sh on every start, so it survives recreation, migration and Reset.

Four things this gets right that are easy to get wrong:

* update-ca-certificates globs *.crt case-sensitively, so a .pem that is merely
  copied in is ignored in silence. Certificates are renamed, by
  container_cert_name() in Rust and a mirrored few lines of shell.
* The system store only serves curl/git/apt. Node — and so Claude Code itself —
  needs NODE_EXTRA_CA_CERTS, Python needs REQUESTS_CA_BUNDLE/SSL_CERT_FILE, and
  Chromium reads neither: it wants ~/.pki/nssdb, seeded with certutil
  (libnss3-tools, added to the image).
* Those vars are set from Rust at creation, never exported by the entrypoint —
  a terminal is a docker exec and sees nothing the entrypoint exported. They are
  emitted empty when no CA is configured, since docker commit bakes env into the
  snapshot image.
* triple-c.ca-fingerprint hashes the certificate bytes as well as the path, so
  a CA rotated in at the same location still forces a recreation.

Verified end to end against a real container and a self-signed CA: curl, node,
python and git all complete a TLS handshake against a server signed by it and
all three fail in the same container without it; the env vars are visible from
a docker exec session; the store is cleaned when the setting is cleared.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSP2KNPhuWKQ4DL5TZEn3k
2026-08-10 10:40:21 -07:00

483 lines
23 KiB
Bash

#!/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/<name>.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)
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|HOME|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
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"