Docker container with web terminal (TTYD) and AI CLI tools (Claude Code, Codex, Gemini CLI, Copilot, OpenCode, Droid, Hermes). Each tool can be excluded at build time for a lighter image — see Модульный состав образа. The web terminal proxy uses a multithreaded HTTP server — concurrent connections do not block each other.
# Build
docker build -t clihost .
# Run
docker run -p 22:22 -p 8080:8080 clihostOpen http://localhost:8080 for web terminal access.
CI runs python -m pytest tests/, npm ci && npm test, and a lightweight Docker build+smoke on every PR and push to main, including /health, sshd, and root-context ~/.ssh symlink hardening checks.
Каждый CLI-инструмент можно исключить из образа на этапе сборки, чтобы получить более лёгкий, заточенный под конкретный деплой образ. Управление — через build-time аргументы INSTALL_<KEY> (по умолчанию true, ставится всё):
| Build arg | Компонент |
|---|---|
INSTALL_CLAUDE_CODE |
Claude Code (@anthropic-ai/claude-code) |
INSTALL_CODEX |
Codex (@openai/codex) |
INSTALL_GEMINI |
Gemini CLI (@google/gemini-cli) |
INSTALL_COPILOT |
GitHub Copilot (@github/copilot) |
INSTALL_OPENCODE |
OpenCode (opencode-ai) |
INSTALL_DROID |
Droid (droid) |
INSTALL_HAPI |
Hapi (@twsxtd/hapi) — ядро рантайма (туннель/runner/URL на дашборде) |
INSTALL_HERMES |
Hermes Agent (Nous Research, ставится через pip) |
# Собрать без Codex и Gemini
docker build --build-arg INSTALL_CODEX=false --build-arg INSTALL_GEMINI=false -t clihost .
# То же через build.sh (он также авто-определяет версии npm для cache-busting)
INSTALL_CODEX=false INSTALL_GEMINI=false ./build.shЭто не runtime-переменные: инструменты устанавливаются во время
docker build, поэтому задавать их черезdocker run -eбесполезно. На Railway укажите их в разделе Build-time variables сервиса — платформа пробросит их вdocker build.
| Variable | Required | Notes |
|---|---|---|
TTYD_PASSWORD |
Required | Password for web terminal access |
PASSWORD_SECRET |
Recommended | Session persistence across restarts |
SECURE_COOKIES |
Set to true |
Railway serves HTTPS automatically |
PORT |
Auto | Injected by Railway, do not set manually |
Add persistent volume at /home/hapi via Railway dashboard → Service → Volumes.
Mount the volume at exactly
/home/hapi, not/home. Mounting a persistent volume at the parent/homebreaks credential persistence on redeploy: a Docker volume is only seeded from the image when it is empty, so once the platform's persistent storage survives the first deploy and stays non-empty, subsequent deploys do not re-copy the image's/home/hapi. The entrypoint then recreates/home/hapi/.claudeempty on top of the volume and the saved Claude Code login is gone (it looks like "auth keeps resetting", issue #69). Mounting/home/hapidirectly keeps the home directory itself as the persisted unit and avoids this. The entrypoint also detects this wrong mount at startup and prints a loudWARNING: a volume is mounted at /home, NOT at /home/hapito the container logs.Migrating an existing
/homevolume: if you already ran with a volume at/home, your data lives under that volume'shapi/subdirectory. Do not just retarget the same volume to/home/hapi— its contents would then land at/home/hapi/hapi/and stay invisible to the app (the very state you wanted to keep looks lost). First move the volume'shapi/contents up to its root, or copy them into a fresh volume mounted at/home/hapi.
- By default SSH (port 22) is not reachable externally on Railway (no arbitrary port forwarding) — web terminal only. To get external SSH, enable the external SSH tunnel below (
SSH_TUNNEL_ENABLED=true). PORTis injected automatically by Railway,ttyd_proxy.pyreads it viaos.environ- When
hapiis installed, the dashboard builds the HAPI Server link on demand from/home/hapi/.hapi/server.log(latesthttps://*.relay.hapi.run) and/home/hapi/.hapi/settings.json(cliApiToken)./home/hapi/urlremains a legacy fallback, but the dashboard no longer depends on the entrypoint's one-shot URL writer. If hapi is absent or the URL/token is not available yet, the menu item is omitted.
The image preconfigures native Claude Code for z.ai GLM Coding Plan through
/home/hapi/.claude/settings.json. On startup, entrypoint.sh copies
config/claude-settings.json from /etc/skel/.claude/settings.json only when
the user's settings.json is missing, so persisted volumes and custom Claude
settings are not overwritten. settings.local.json is a separate Claude Code
file and is never touched by this bootstrap.
The template contains only non-secret Claude Code env settings:
ANTHROPIC_BASE_URL=https://api.z.ai/api/coding/paas/v4,
Sonnet/Opus glm-5.2[1m], Haiku glm-4.7, and the long timeout/context
window settings. It does not contain a token.
For native claude, pass the z.ai Coding Plan token at runtime:
docker run --env-file .env -e ANTHROPIC_AUTH_TOKEN=your_zai_token clihostbin/glm is still shipped for compatibility and still reads ZAI_TOKEN, but it
is no longer required for GLM: ordinary claude uses the native settings file
when ANTHROPIC_AUTH_TOKEN is present in the environment.
Для обычного Claude Code OAuth (claude login, файл
/home/hapi/.claude/.credentials.json) в образе есть диагностический snapshot
без секретов: токенов в снэпшоте нет, сохраняются только expiresAt, scopes,
тип подписки, sha256/mtime файла, права, время и быстрый сетевой чек Anthropic.
Снимите baseline сразу после успешного claude login внутри живого контейнера:
docker exec <container> bash /bin/claude-auth-snapshot.sh snapshot baselineКогда Claude Code разлогинился, снимите второй snapshot и сравните:
docker exec <container> bash /bin/claude-auth-snapshot.sh snapshot failed
docker exec <container> bash /bin/claude-auth-snapshot.sh diff
docker exec <container> bash /bin/claude-auth-snapshot.sh listПо умолчанию файлы пишутся в
/home/hapi/.hapi/auth-snapshots; при необходимости путь меняется через
CLAUDE_AUTH_SNAPSHOT_DIR. diff выдаёт подсказку причины: refresh работает,
refresh не происходит при истёкшем expiresAt, права мешают записи
.credentials.json, пропал доступ к api.anthropic.com, либо credentials-файл
исчез.
С хоста используйте wrapper, чтобы добавить docker inspect-мету
(RestartCount, State.StartedAt, State.Status, mounts) и отделить
mount/persistence-рестарт от token/refresh-проблемы:
bin/claude-auth-snapshot-host.sh <container> baseline
bin/claude-auth-snapshot-host.sh <container> failed
bin/claude-auth-snapshot-host.sh diff ./claude-auth-host-snapshots/<A>.json ./claude-auth-host-snapshots/<B>.jsonЭтот инструмент диагностический: он разделяет (а) mount/persistence, (б) права/refresh-запись, (в) сетевой или token-refresh провал, но сам OAuth не чинит.
bin/clihost-sync.sh запускается с хоста и синхронизирует через
rsync-over-SSH только config-level subset с узким include-list, а не строго
несекретный набор:
~/.gitconfig~/.config/gh
~/.config/gh может содержать GitHub OAuth-токен в plaintext в hosts.yml
(oauth_token:), если gh не использует системный keyring. Такой режим типичен
для headless-серверов и контейнеров. Синк ~/.config/gh — осознанный выбор:
этот токен пересекает SSH relay вместе с config-level данными.
Команда подключается к sshd контейнера под пользователем hapi. В этом
контракте pull означает хост → контейнер, а push — контейнер → хост.
По умолчанию это dry-run с itemized output от rsync; реальная запись требует
--apply. --delete не используется без явного --allow-delete.
CLIHOST_SSH_TARGET=hapi@127.0.0.1 CLIHOST_SSH_PORT=2222 bin/clihost-sync.sh pull
CLIHOST_SSH_TARGET=hapi@127.0.0.1 CLIHOST_SSH_PORT=2222 bin/clihost-sync.sh pull --apply
CLIHOST_SSH_TARGET=hapi@127.0.0.1 CLIHOST_SSH_PORT=2222 bin/clihost-sync.sh push
CLIHOST_SSH_TARGET=hapi@127.0.0.1 CLIHOST_SSH_PORT=2222 bin/clihost-sync.sh ssh
CLIHOST_SSH_TARGET=hapi@127.0.0.1 CLIHOST_SSH_PORT=2222 bin/clihost-sync.sh ssh pull --applyДля прямого SSH используйте CLIHOST_SSH_TARGET, CLIHOST_SSH_PORT и при
необходимости CLIHOST_SSH_IDENTITY_FILE либо одноимённые флаги. Для туннеля
#79: chisel обычно выглядит как обычный ssh -p <port>, а cloudflared удобнее
завести в локальном ~/.ssh/config как Host с ProxyCommand, после чего
передать helper-у этот Host как target.
ssh-подкоманда отделена от обычного pull/push и выключена по умолчанию.
Без --include-private-keys она переносит только ~/.ssh/known_hosts,
~/.ssh/*.pub и ~/.ssh/config. Приватные ключи требуют явного флага, потому
что ключевой материал проходит через SSH relay; это risk B relay/blast-radius
из #17. Детектор приватных ключей PEM-only, а контракт ~/.ssh flat: копируются
только top-level файлы, ключи в поддиректориях проверяются по правам, но не
переносятся.
Non-goals:
~/.claudeне синкается: источник истины — volume/home/hapi. Синк мог бы затереть свежие OAuth credentials во время refresh-token гонки (#69/#89).- Приватные
~/.sshне входят в основной subset из-за blast radius и relay-риска; для них есть отдельная явнаяssh-подкоманда. - Claude Code
settings.jsonне синкается: он управляетсяconfig/claude-settings.jsonи bootstrap-контрактом (#60), иначе легко разнести устаревшую локальную конфигурацию.
Перед каждым запуском remote preflight работает fail-closed: если home на
удалённой стороне не является отдельным mount ровно в /home/hapi или
/proc/mounts нельзя прочитать, rsync не стартует. Parent mount на /home
вызывает abort только при отсутствии отдельного /home/hapi mount; если
смонтированы оба, preflight считает /home/hapi достаточным и sync продолжается.
В таком случае исправьте volume mount на /home/hapi; если раньше
использовался /home, перенесите содержимое hapi/ в корень нового volume, как
описано выше.
Recovery: в режиме --apply helper включает rsync --backup и
--backup-dir. Для pull backups остаются в контейнере под
/home/hapi/.clihost-sync-backups/<timestamp>, для push — на хосте под
~/.clihost-sync-backups/<timestamp>. Чтобы откатиться, возьмите нужные файлы
из последнего backup-dir и скопируйте их обратно в сторону назначения.
~/.claude восстанавливается не через sync, а из корректно смонтированного
/home/hapi volume как known-good источника.
Syncthing и real-time sync отложены: текущий релиз намеренно rsync-first, без постоянного фонового синка.
Browser → HTTP/WebSocket → ttyd_proxy.py (8080) → TTYD (127.0.0.1:768x) → tmux → Shell
SSH Client → sshd (22) → Shell
hapi Client → hapi runner (80) → CLI tools
Процессы внутри контейнера:
| Процесс | Порт | Описание |
|---|---|---|
sshd |
22 | SSH-доступ, main-процесс контейнера |
ttyd_proxy.py |
8080 | Multithreaded HTTP/WS прокси с аутентификацией |
ttyd |
127.0.0.1:768x | Web-терминал (по одному на сессию, только localhost) |
droid daemon --remote-access |
— | Optional Droid gateway, logs to /home/hapi/.hapi/droid-daemon.log |
ao daemon |
127.0.0.1:3001 | Optional agent-orchestrator gateway (loopback-only), logs to /home/hapi/.hapi/ao-daemon.log |
hapi runner |
HAPI_PORT (default 80) |
Запуск CLI-инструментов по API (опционально) |
hapi server --relay |
— | Туннель для внешнего доступа |
Railway: платформа проксирует внешний трафик (порт 80/443) на внутренний порт 8080 контейнера.
ttyd_proxy.pyостаётся на 8080, hapi runner — на 80. Конфликта нет: Railway форвардит только на 8080, а hapi runner доступен снаружи исключительно через relay-туннель.
Поток аутентификации: POST /login → HMAC-подписанная cookie ttyd_session → редирект на /.
Мультитерминальность: каждый терминал — отдельный процесс ttyd на своём порту (7681, 7682, …). Управление через GET/POST/DELETE /terminals.
После рефакторинга точка входа app/ttyd_proxy.py остаётся совместимой оболочкой, а основная логика разложена по модульному пакету app/ttydproxy/:
config.py— env-конфигурация и route-константыassets.py— загрузка статических terminal HTML/JS/CSS-ассетовsecurity.py— cookies, signed tokens, CSRF, username validationmanager.py— lifecycle ttyd/tmux процессовproxy.py— HTTP/WebSocket proxy и HTML injectionratelimit.py— in-memory rate limiting helpersviews.py— рендер login/menu/terminal страницapp.py— wiring handler'а и запуск сервера
Terminal iframe page и связанные JS/CSS-ассеты лежат в app/terminal.html и app/assets/.
- 22 - SSH access
- 8080 - Web terminal (TTYD)
| Variable | Default | Description |
|---|---|---|
PORT |
8080 | Web terminal HTTP proxy port |
TTYD_USER |
hapi | Terminal user |
TTYD_PASSWORD |
- | Global password for web terminal (uses system passwords if not set) |
PASSWORD_SECRET |
auto | Secret for session signatures (auto-generated if not set) |
ROOT_PASSWORD |
- | Enable root SSH access with this password |
DROID_DAEMON_ENABLED |
false | Start droid daemon --remote-access for Droid remote access |
DROID_COMPUTER_NAME |
- | Required when DROID_DAEMON_ENABLED=true; used for non-interactive droid computer register <name> -y |
AO_DAEMON_ENABLED |
false | Start ao daemon (agent-orchestrator); loopback-only, reach it through the SSH tunnel |
AO_PORT |
3001 | ao daemon bind port (loopback-only by design; understood by the daemon itself) |
TTYD_SANDBOX |
false | Wrap each tmux session in a bubblewrap jail (see Multi-tenant sandbox below) |
ANTHROPIC_AUTH_TOKEN |
- | z.ai Coding Plan token for native Claude Code GLM access; do not bake it into the image or settings file |
ZAI_TOKEN |
- | Compatibility token for the legacy bin/glm wrapper only |
TTYD_SANDBOX=true launches every tmux session inside a bubblewrap (bwrap) jail so that, in multi-tenant deployments where each terminal runs as a different Linux user, users can't read each other's /home/* and system directories are read-only. It is off by default — single-user clihost is unchanged.
The jail requires the container to run with --security-opt seccomp=unconfined (Docker's default seccomp profile blocks the namespace syscalls bwrap needs). On Dokku:
dokku docker-options:add <app> deploy,run "--security-opt seccomp=unconfined"The jail keeps network access (AI CLIs need it) and is fail-closed: if it can't start, the terminal doesn't open rather than falling back to an unsandboxed shell.
Exposes the container's sshd (port 22) externally where arbitrary port forwarding is unavailable (Railway and similar PaaS). Pluggable: both providers ship in the image, selected by SSH_TUNNEL_PROVIDER. Independent of hapi — works with INSTALL_HAPI=false. Off by default.
| Variable | Default | Description |
|---|---|---|
SSH_TUNNEL_ENABLED |
false | Enable the external SSH tunnel |
SSH_TUNNEL_PROVIDER |
cloudflared | cloudflared or chisel |
CLOUDFLARE_TUNNEL_TOKEN |
- | Required for cloudflared; named-tunnel token (cloudflared tunnel run --token …) |
CLOUDFLARE_TUNNEL_HOSTNAME |
- | Public hostname (e.g. ssh.example.com) for the dashboard connection string |
CHISEL_SERVER |
- | Required for chisel; URL of a self-hosted chisel server --reverse |
CHISEL_AUTH |
- | user:pass credential (same on server and client) |
CHISEL_REMOTE_PORT |
2222 | Server port the reverse-forwarded :22 listens on |
cloudflared uses a named tunnel + token (requires a Cloudflare account + domain). Quick tunnels (trycloudflare) are HTTP-only and cannot carry SSH. chisel requires you to run your own public
chisel server --reverse.
Connecting from the client:
# cloudflared (needs cloudflared installed locally):
ssh -o ProxyCommand="cloudflared access ssh --hostname %h" hapi@$CLOUDFLARE_TUNNEL_HOSTNAME
# chisel (plain ssh):
ssh -p $CHISEL_REMOTE_PORT hapi@<host of CHISEL_SERVER>
# to reach `ao daemon` (loopback-only) through the tunnel, add:
# -L 127.0.0.1:3001:127.0.0.1:3001| Variable | Default | Description |
|---|---|---|
HAPI_RUNNER_ENABLED |
false | Enable hapi runner |
CLI_API_TOKEN |
- | Auth token (required if runner enabled) |
HAPI_API_URL |
- | Server URL (required if runner enabled) |
HAPI_HOST |
0.0.0.0 | Runner bind address |
HAPI_PORT |
80 | Runner port |
Basic usage (web terminal + SSH only):
docker run -p 22:22 -p 8080:8080 clihostWith persistent volume:
docker run -p 22:22 -p 8080:8080 \
-v "$(pwd)/volume/hapi:/home/hapi" \
clihostWith hapi runner:
docker run -p 22:22 -p 8080:8080 \
-e HAPI_RUNNER_ENABLED=true \
-e CLI_API_TOKEN=your_token \
-e HAPI_API_URL=https://your-server.com \
clihostWith Droid remote access:
docker run -p 22:22 -p 8080:8080 \
-e DROID_DAEMON_ENABLED=true \
-e DROID_COMPUTER_NAME=clihost \
-v "$(pwd)/volume/hapi:/home/hapi" \
clihostcurl http://localhost:8080/health
# {"status": "ok", "ttyd": "running"}