From 17de4051387ba43756797ac6a668ef04c601da9b Mon Sep 17 00:00:00 2001 From: Rohit Singhal Date: Wed, 19 Aug 2026 23:46:23 +0530 Subject: [PATCH 1/2] deploy: container image for Dockerfile-based PaaS (Dokku) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships the full session runtime — tmux, ttyd, git, gh and the claude CLI — on node:22-bookworm-slim, with both bundles built in a builder stage. Runs as uid 1000, not root: the unattended lane launches claude with --dangerously-skip-permissions, which the CLI refuses under root. $HOME is a real writable directory (the deploy mounts it) because claude-launch.sh seeds ~/.claude.json in the home root to accept the folder-trust dialog, and because the credential dir and transcripts the console reads back both live there. en_US.UTF-8 is generated since session-backend.ts hardcodes it into every pane. No host, tenant or token appears here — deployment identity is runtime config. Co-Authored-By: Claude Opus 5 (1M context) --- .dockerignore | 33 ++++++++++++ CHANGELOG.md | 11 ++++ CHECKS | 7 +++ Dockerfile | 125 +++++++++++++++++++++++++++++++++++++++++++ docker-entrypoint.sh | 19 +++++++ 5 files changed, 195 insertions(+) create mode 100644 .dockerignore create mode 100644 CHECKS create mode 100644 Dockerfile create mode 100755 docker-entrypoint.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..cf18e13a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,33 @@ +# Build artifacts and installs — all rebuilt inside the image. Shipping a stale local dist/ is the +# classic "why isn't my change taking" bug. +node_modules +dist +web/node_modules +web/dist + +# The data home. NEVER ship a DB, secret.key, audit trail or tmux socket into an image. +data +**/*.db +**/*.db-wal +**/*.db-shm + +# VCS + local identity +.git +.github +.env +.env.example +config/tenants.json + +# Docs and history — nothing under src/ reads them at runtime. +docs +deploy +CHANGELOG.md +README.md +TODO.md +LICENSE + +# Test + dev scaffolding +test +scripts/*.cjs +*.log +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index 90e94fea..1919ca95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ Every PR that bumps `package.json` moves its entries from **Unreleased** into a new version heading in the same commit. ## [Unreleased] +### Added +- **Container image — Agentric now deploys to a Dockerfile-based PaaS.** A multi-stage `Dockerfile` + (plus `.dockerignore`, `CHECKS` and `docker-entrypoint.sh`) builds both bundles and ships the full + session runtime — tmux, ttyd (pinned static release binary), `git`, `gh` and the `claude` CLI — on + `node:22-bookworm-slim`. Three things it deliberately gets right: it runs as **uid 1000, not root** + (the unattended lane's `--dangerously-skip-permissions` is refused under root); `$HOME` is a real + writable directory the deploy mounts as a volume (the `~/.claude.json` trust seed, the credential + dir the TUI lane actually authenticates with, and the transcripts the conversation view reads all + live there); and it generates `en_US.UTF-8`, the locale `session-backend.ts` hardcodes into every + tmux pane. `AOS_UID_ISOLATION` stays off, so the app's own `/terminal/` proxy means one published + port is enough. Carries no host, tenant or token — all deployment identity stays in runtime config. ## [0.374.0] ### Added diff --git a/CHECKS b/CHECKS new file mode 100644 index 00000000..5a952cfc --- /dev/null +++ b/CHECKS @@ -0,0 +1,7 @@ +# Boot does real work before the port opens: SQLite migrations, tenant registry, owner seeding, +# ttyd spawn, scheduler + chat sockets. Give it room rather than failing a healthy deploy. +WAIT=5 +TIMEOUT=30 +ATTEMPTS=18 + +/health diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..96640573 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,125 @@ +# syntax=docker/dockerfile:1 +# +# Agentric (agent-os) — container image for a Dockerfile-based PaaS (Dokku/Heroku-style). +# +# FULL runtime: the Node server, the React console, and the native tools every agent session shells +# out to (tmux, ttyd, claude, git — see src/edge/deps.ts). `AOS_UID_ISOLATION` stays OFF, so the app +# itself reverse-proxies /terminal/ (HTTP + the ttyd WebSocket upgrade) to a loopback ttyd and ONE +# published port is enough (`sharedTerminalProxy` / `sharedTerminalUpgrade` in src/server.ts). +# +# Three constraints shape this file; none are optional: +# +# 1. NOT root. The unattended lane launches claude with `--dangerously-skip-permissions`, which the +# CLI refuses under uid 0. So the runtime user is `node` (uid 1000). +# 2. $HOME must be a real, writable, PERSISTENT directory. `terminal/claude-launch.sh` pre-seeds +# `~/.claude.json` (in the home ROOT, temp-file + rename) to accept Claude Code's folder-trust +# dialog — if that write fails, every session hangs on the trust prompt forever, and +# `--dangerously-skip-permissions` does NOT dodge it. HOME also holds `.claude/.credentials.json` +# (the only credential the TUI lane actually authenticates with) and `.claude/projects/*.jsonl` +# (the transcripts the console's conversation view + cost reporting read back). +# 3. en_US.UTF-8 must EXIST. src/edge/session-backend.ts hardcodes `LANG: 'en_US.UTF-8'` into every +# tmux pane; tmux string-matches it for "UTF-8" to decide UTF-8 mode. Without the generated +# locale, glibc falls back to POSIX/ASCII and the claude TUI's box drawing mangles. +# +# No hostnames, IPs, emails or tokens belong here — deployment identity is all runtime config. + +######################## build ######################## +FROM node:22-bookworm-slim AS build +WORKDIR /app + +# Root bundle has no runtime deps; typescript is a devDependency. --omit=optional skips +# @libsql/client (lazily imported only when memory.backend=libsql) and its native prebuilds. +COPY package.json package-lock.json ./ +RUN npm ci --omit=optional --no-audit --no-fund + +# The console is a separate npm project with its own lockfile. +COPY web/package.json web/package-lock.json ./web/ +RUN cd web && npm ci --no-audit --no-fund + +COPY tsconfig.json ./ +COPY src ./src +RUN npm run build + +COPY web ./web +RUN cd web && NODE_OPTIONS=--max-old-space-size=1536 npm run build + +######################## runtime ######################## +FROM node:22-bookworm-slim AS runtime + +ARG TTYD_VERSION=1.7.7 +ARG GH_VERSION=2.97.0 +ARG CLAUDE_VERSION=latest +ENV DEBIAN_FRONTEND=noninteractive + +# tmux + git are declared deps; curl is used by the terminal/*.sh hooks; procps supplies `ps` for +# pane liveness; tini reaps the tmux/claude grandchildren that double-fork out of node's tree. +RUN apt-get update && apt-get install -y --no-install-recommends \ + bash ca-certificates curl git openssh-client tmux procps tini locales \ + jq ripgrep less unzip xz-utils \ + && sed -i 's/^# *\(en_US\.UTF-8 UTF-8\)/\1/' /etc/locale.gen \ + && locale-gen \ + && locale -a | grep -qi '^en_US\.utf8$' \ + && rm -rf /var/lib/apt/lists/* + +# ttyd: the official static release binary. Not reliably packaged in bookworm-slim, and the static +# build has no shared-library deps, so it drops into a glibc image unchanged and pins an exact version. +RUN curl -fsSL -o /usr/local/bin/ttyd \ + "https://github.com/tsl0922/ttyd/releases/download/${TTYD_VERSION}/ttyd.x86_64" \ + && chmod 0755 /usr/local/bin/ttyd \ + && ttyd --version + +# gh: agents do GitHub work through it (the per-member GitHub token is injected into their shell). +RUN curl -fsSL "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_amd64.tar.gz" \ + | tar -xz -C /tmp \ + && mv "/tmp/gh_${GH_VERSION}_linux_amd64/bin/gh" /usr/local/bin/gh \ + && rm -rf /tmp/gh_* \ + && gh --version + +# The agent runtime each session launches. Installed under /opt — NOT under HOME, which is mounted +# over at runtime and would hide it — and owned by `node` so an in-place upgrade works. +ENV NPM_CONFIG_PREFIX=/opt/npm-global +ENV PATH=/opt/npm-global/bin:$PATH +RUN mkdir -p /opt/npm-global \ + && npm install -g "@anthropic-ai/claude-code@${CLAUDE_VERSION}" \ + && chown -R node:node /opt/npm-global \ + && claude --version + +WORKDIR /app + +# Keep the step even though the root package has no runtime deps today, so a future one is honoured. +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev --omit=optional --no-audit --no-fund && npm cache clean --force + +# baseDir is dist/.. = /app, so terminal/, config/, public/, web/dist/ and package.json +# (read by src/version.ts) all have to land here. +COPY --from=build /app/dist ./dist +COPY --from=build /app/web/dist ./web/dist +COPY terminal ./terminal +COPY config ./config +COPY public ./public +COPY bin ./bin +COPY scripts/install-deps.sh ./scripts/install-deps.sh +COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh + +RUN chmod +x /usr/local/bin/docker-entrypoint.sh \ + && ln -sf /app/bin/agent-os /usr/local/bin/agent-os + +ENV NODE_ENV=production \ + HOME=/home/node \ + LANG=en_US.UTF-8 \ + LC_ALL=en_US.UTF-8 \ + PORT=3010 \ + TTYD_PORT=3011 \ + AGENT_OS_HOME=/data \ + DISABLE_AUTOUPDATER=1 + +# Both /data and /home/node are bind-mounted at runtime; create and chown them so the mount targets +# exist and an unmounted run still works. Ownership by uid 1000 is what makes constraint 2 hold. +RUN mkdir -p /data /home/node/.claude \ + && chown -R node:node /data /home/node /app + +USER node +EXPOSE 3010 + +ENTRYPOINT ["/usr/bin/tini", "-s", "--", "/usr/local/bin/docker-entrypoint.sh"] +CMD ["node", "dist/cli.js", "serve"] diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 00000000..00e06606 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,19 @@ +#!/bin/sh +# Prepare the two mounted directories before handing off to the server. +# +# Both $HOME and $AGENT_OS_HOME are bind mounts, so whatever the image baked at those paths is +# hidden at runtime. `~/.claude` in particular has to exist and be writable before the first agent +# session launches — claude-launch.sh seeds `~/.claude.json` and the CLI keeps its credentials and +# transcripts under `~/.claude`. A read-only or missing HOME is the folder-trust-dialog hang. +set -e + +mkdir -p "${AGENT_OS_HOME:-/data}" "${HOME}/.claude" + +if ! touch "${HOME}/.aos-write-probe" 2>/dev/null; then + echo "FATAL: \$HOME (${HOME}) is not writable — every agent session would hang on Claude Code's" >&2 + echo " folder-trust dialog. Fix the volume ownership (uid $(id -u)) and redeploy." >&2 + exit 1 +fi +rm -f "${HOME}/.aos-write-probe" + +exec "$@" From 2b0195191b2879dab46ed5159d9ad9f97cbc4a66 Mon Sep 17 00:00:00 2001 From: Rohit Singhal Date: Thu, 10 Sep 2026 16:55:16 +0530 Subject: [PATCH 2/2] deploy: container image for a Dockerfile-based PaaS (v0.433.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-stage Dockerfile plus .dockerignore, CHECKS and docker-entrypoint.sh, building both bundles and shipping the full session runtime (tmux, ttyd, git, gh, claude) on node:22-bookworm-slim. Three constraints the image has to hold, all load-bearing: - it runs as uid 1000, not root — the unattended lane launches claude with --dangerously-skip-permissions, which the CLI refuses under uid 0; - $HOME is a real writable directory the deploy mounts as a volume, because claude-launch.sh seeds ~/.claude.json to pre-accept the folder-trust dialog and a failed seed hangs every session on it; the credentials and the transcripts the console reads back live under the same home; - en_US.UTF-8 is generated, since session-backend.ts hardcodes it into every tmux pane and tmux string-matches it to decide UTF-8 mode. AOS_UID_ISOLATION stays off, so the app's own /terminal/ proxy keeps one published port enough. No host, tenant, email or token is baked in — all deployment identity stays runtime config. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 ++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ff4ff71..6e33766a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ Every PR that bumps `package.json` moves its entries from **Unreleased** into a new version heading in the same commit. ## [Unreleased] + +## [0.433.0] - 2026-09-10 ### Added - **Container image — Agentric now deploys to a Dockerfile-based PaaS.** A multi-stage `Dockerfile` (plus `.dockerignore`, `CHECKS` and `docker-entrypoint.sh`) builds both bundles and ships the full diff --git a/package-lock.json b/package-lock.json index bbd3a087..6962eb9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-os", - "version": "0.432.0", + "version": "0.433.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-os", - "version": "0.432.0", + "version": "0.433.0", "license": "MIT", "bin": { "agent-os": "bin/agent-os" diff --git a/package.json b/package.json index b53e6c4d..34bd60c2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-os", - "version": "0.432.0", + "version": "0.433.0", "description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.", "license": "MIT", "type": "commonjs",