Skip to content

Latest commit

 

History

History
696 lines (581 loc) · 50.7 KB

File metadata and controls

696 lines (581 loc) · 50.7 KB

Operations runbook

Backup and restore

Stop the daemon before restoring. Interactive restores require typing RESTORE; unattended automation additionally requires the explicit CG_RESTORE_CONFIRM=YES environment flag.

npm run backup creates a transactionally consistent SQLite snapshot with VACUUM INTO, packages the durable config/channel metadata, and encrypts it with AES-256-CBC/PBKDF2. Store the blob and key separately off-host. Set CG_BACKUP_PASSPHRASE or CG_BACKUP_KEY_FILE in unattended environments. Run npm run restore:drill after every material upgrade and at least monthly; it restores into a disposable directory and runs SQLite quick_check. Stop the service before a real npm run restore.

Both scripts read the database from CLAUDE_GATEWAY_DB when it is set (the same override src/config/paths.js honors), falling back to $CHANNELGATE_DIR/gateway.db. A restore replaces the managed config/ and channels/ directories rather than merging into them: each is staged beside its destination, swapped in with mv, and the previous copy is removed only after the swap succeeds — so a restored gateway never carries files the backup does not contain. If the archive step fails, npm run backup aborts instead of writing a partial blob.

Backup scope: npm run backup covers the gateway's operational state — gateway.db, config, and the gated ~/.channelgate/channels/ folders. It deliberately does NOT include the visible channel workspaces (~/ChannelGate/<platform>/<slug>/ or per-channel custom folders): those are working directories, usually git repositories with their own remotes. Back them up separately if their contents aren't pushed anywhere.

Service identities

Linux production installs use sudo bash scripts/install-systemd.sh, which creates the non-login channelgate account and a mode-0700 /var/lib/channelgate (an install upgraded from the pre-rename release keeps its existing claude-gateway account and /var/lib/claude-gateway, so no data moves underneath it; only a fresh install gets the new names). It also chowns the checkout to that account and verifies the account can write it — self-update runs git and npm ci as the service user, which fails on a root-owned checkout. A single-user box may instead run a user-scope unit (~/.config/systemd/user/channelgate.service, with loginctl enable-linger so it survives logout); the updater and the migration find both scopes. sudo bash scripts/uninstall-systemd.sh (npm run service:uninstall) removes either unit — current and pre-rename names — and leaves the service account and runtime root in place. The service must never share credentials or a runtime root with interactive development. ChannelGate runs on Linux only; there is no other service packaging.

Engine credentials — Claude uses the host user's own login. The gateway authenticates Claude with the claude sign-in of the user the daemon runs as ($CLAUDE_CONFIG_DIR, else ~/.claude) — the same one that user maintains in their own shell. Keep that signed in and there is nothing else to configure: the daemon reads the login where it lives, relays its current access token to each container run, and refreshes it on the host when it gets close to expiry. The credentials file is never copied, linked or mounted, because Claude Code rotates the refresh token on every refresh and a second copy that refreshes logs the first one out.

A login session expires every few weeks and only a new interactive claude login moves that date. The daemon prints the source and the date at boot ([gateway] claude login: operator (…), session expires …), warns three days ahead, and shows the same line in /status and /api/health. Because a daemon that has been up for weeks would otherwise never say it again, an hourly watch re-checks the login while it runs: the gateway DMs every admin daily from three days before the login expires, and daily while it is missing (once per UTC day per message class, remembered in the database so a restart cannot turn it into a spam loop). The DM names the login, its expiry in UTC and in the gateway's local timezone, and the remedy — never any token material. When it lapses, run claude as the daemon's user and sign in again — no restart needed, and the next hourly check goes quiet on its own.

Two alternatives remain, in this precedence: a claude setup-token value pasted into Settings → Container runtime (long-lived, never rotates), and the daemon's own ANTHROPIC_API_KEY. A login signed in to the gateway's own engine home (<runtime root>/engine-state/claude/home/.claude) also still counts, behind the host user's.

Measured, not assumed: the relayed token rides the child's environment, but Claude Code strips CLAUDE_CODE_OAUTH_TOKEN (and ANTHROPIC_API_KEY) from the environment of every Bash tool subprocess — verified on 2026-09-02 on this host: a tool call running sh -c 'echo ${#CLAUDE_CODE_OAUTH_TOKEN}' printed 0 while HOME was visible — so an agent cannot printenv it from inside its container. What still inherits the process environment is a stdio MCP server Claude Code itself spawns; only the gateway's own bridges and admin-curated catalog servers are ever injected, and they run as the same user in any case. The token is the ACCESS half only (hours), never the refresh token.

Engine credentials on Linux: the service account cannot run claude login (no login shell, its own empty home), so the engines authenticate via API keys. The installer creates a 0600 <runtime root>/service.env template read by the unit (EnvironmentFile=): fill in ANTHROPIC_API_KEY (and OPENAI_API_KEY for Codex), then systemctl restart channelgate. The installer also resolves the claude/codex binaries at install time and bakes their directories into the unit's PATH — prefer system-wide CLI installs (e.g. npm prefix /usr/local); a CLI inside a user home is exposed to the service read-only with a warning. Without keys the daemon starts and serves the admin UI, but every engine turn fails authentication — this file is the fix, not claude login. The containers honor the same key: with no gateway login to relay, ANTHROPIC_API_KEY in the daemon's environment authenticates Claude inside every container (it crosses through the reviewed passthrough list in src/engines/child-env.js), so a keyed install never sees "no Claude login to relay".

Reverse proxies and tunnels

The daemon binds 127.0.0.1. The supported way to reach it from outside the host — for the admin UI over the internet, and for approval links a phone can open — is a tunnel or reverse proxy in front of it on the SAME machine (cloudflared, nginx). Set Settings → Connection → Public URL to the public address: it is what approval links are built from, and its hostname is automatically allowed past the DNS-rebinding guard (extra names go in CG_ALLOWED_HOSTS).

Client addresses behind that hop. Every request then arrives on a loopback socket, so req.ip — and Express's idea of the client — is 127.0.0.1 for every caller on earth. Two things count failures per address: the admin login backoff and the approval-link token backoff. Keyed on the socket they would share ONE bucket, and a handful of bad tokens or passwords from anywhere would lock out every legitimate admin and every valid approval link. So both read the real client through one helper (clientKey in src/web/security.js): CF-Connecting-IP first, then the first hop of X-Forwarded-For, and only when the socket itself is loopback — from a non-loopback socket those headers are whatever the client typed and are ignored. A value that is not a valid IP address is ignored too.

Notes for operators:

  • Express's app-wide trust proxy is deliberately NOT enabled: it would also re-point req.secure / req.protocol / req.hostname at client-supplied headers (the Host/Origin guard and the session cookie's Secure flag read those headers themselves, deliberately), and it knows nothing about CF-Connecting-IP.
  • If your proxy runs on ANOTHER host, the socket is not loopback and the headers are ignored by default. Set CG_TRUST_PROXY=1 in .env to honour X-Forwarded-For there — only do this when a proxy you control rewrites that header, or clients can pick their own limiter bucket.
  • Make the proxy set X-Forwarded-For rather than append to a client-supplied one. Where it appends (Cloudflare does), CF-Connecting-IP is the unforgeable value and is preferred here; a caller who spoofs the first XFF hop can only shed its own backoff, never push someone else into one.

The ChannelGate rename migration

scripts/migrate-channelgate.mjs moves a pre-rename install onto the current layout: ~/.claude-gateway/~/.channelgate/, ~/Slack Agent/<slug>/~/ChannelGate/<platform>/<slug>/, and, inside the runtime root, channels/<slug>channels/<platform>/<slug> plus the same for clean-workspaces/. It runs automatically at boot (before the database opens and before Slack connects) and as a post-update step, and it is idempotent.

What counts as "already migrated" is STATE, not existence. The migration runs when the old root holds a gateway.db or a config/ directory and the new root holds neither. A ~/.channelgate/ that exists but is empty of state — a stray mkdir, an engine probe, a test that forgot to pin its environment — is treated as a leftover and the old install is MERGED into it, entry by entry; nothing at the destination is ever overwritten, and a colliding name is logged with its old copy left in place for you to reconcile. If the new root already holds gateway state it is never touched: the run says so and leaves the old root alone. The same rule applies to the workspace: an empty ~/ChannelGate/ is not a reason to skip the per-channel moves.

Before it moves anything it refuses if the pre-rename deployment is still working: a gateway.lock held by a live pid (the daemon), an update.lock held by a live pid (a self-update transaction), or any bg_jobs row whose recorded pid is still alive (a detached background shell whose cwd is inside the old workspace). A refusal moves nothing.

What gets repathed

Moving the folders is the easy half. Every store that RECORDS an absolute path has to follow, or the daemon comes back pointing at directories that no longer exist:

Store What moves
Database JSON blobs every data column in the schema — channel workDir, background-job cwd/logFile, approval and run records, anything a later migration adds
Database typed columns every non-blob TEXT column holding a path (usage_repair_batches.backup_path today)
Config JSON config/settings.json, config/mcp-catalog.json (a local MCP server's command/args can name a path), the pre-SQLite users.json/channels.json/schedules.json backups, update-state.json, each update backup's manifest.json, and the per-channel legacy meta.json/sessions.json/thread-*.json
Claude Code sessions <engineHome>/.claude/projects/<encoded-cwd>/ is renamed to the encoding of the channel's NEW cwd, and the cwd field inside every transcript (including per-session subagents/ and tool-results/) follows — only cwd, see "state vs. content" below. This is what keeps /resume working: claude -r <id> finds the project from the cwd it is launched in, so without the rename every thread's history is invisible. projects/ is normally a symlink into the operator's real ~/.claude, and is resolved before anything under it is touched
Claude engine home .claude.json (both copies) keys a projects map by absolute cwd
Codex state the threads index's rollout_path column, each rollout's session_meta/turn_context cwd + workspace_roots, config.toml's [projects."…"] sections, and shell_snapshots
Work folder text MEMORY.md, memory/<topic>.md, CLAUDE.md/AGENTS.md — the agent's own prose can quote a path it was told to use
Service definitions the installed systemd user unit (its log paths name the runtime root)
Per-channel lockdown .claude/settings.json and settings-admin.json are REGENERATED, not rewritten
Per-channel runtime/ the content-addressed run caches (claude-settings/<digest>.json, claude-plugins/<digest>/) are DELETED. Their filename is a digest of their contents, so rewriting one would leave a file whose name no longer describes it; the next run recreates what it needs

Two things are deliberately left alone: the historical events log (rewriting an audit trail would make it say something that did not happen) and any path outside the roots that moved — a custom per-channel workDir, or the repo checkout named in a unit's WorkingDirectory.

State vs. content: what may be rewritten at all

An engine transcript is two things in one file. A few fields are state — the engine reads them back to restore a session — and everything else is content: a record of what was said and done. A message that quoted ~/Slack Agent/ops, a cat whose stdout printed it, a shell command that ran there. Rewriting content would edit history to say something that never happened, so the migration never touches it and --verify never bills it.

The decision is made per JSON key path, never by substring, and the lists are deliberately tiny. Anything not on one is content.

Store State keys Everything else
Claude transcripts (projects/**/*.jsonl, subagents/*.json) cwd — the session's working directory, what claude -r and the migration's own project-dir resolution read back message.content[], toolUseResult.* (including stdout, filePath), attachment.*, and snapshot.trackedFileBackups, whose object KEYS are paths — object keys are never rewritten by anything
Codex rollouts (sessions/**/*.jsonl, format v0.143) payload.cwd (the session_meta header and each turn_context) and payload.workspace_roots[] payload.state.* under a world_state item, plus every message, argument and tool output

The filesystem key. world_state.payload.state.environments.filesystem is a rendered XML blob (<filesystem><workspace_roots><root>…</root></workspace_roots><permission_profile …>) that Codex emits once per turn, next to the turn_context it was derived from, as the environment context shown to the model. It is content, not state: Codex does not read it back to locate anything — on resume it re-derives the world state from the restored session_meta/turn_context cwd, which is repathed. The same goes for its neighbours state.agents_md.directory (the label on an inlined copy of an AGENTS.md) and state.environments.environments.local.cwd (the same cwd, already covered authoritatively). Repathing the two authoritative fields is what actually moves a session; rewriting the snapshot would only rewrite what the model was once told.

--verify

node scripts/migrate-channelgate.mjs --verify

A read-only sweep of every store above, grouped and counted, exiting 1 if any state still points at a pre-rename root. It opens the database read-only and writes nothing, so it is safe while the daemon runs — use it as a pre-flight and again as the acceptance check afterwards, where it must print 0. --dry-run ends with the same audit, so completeness can be judged BEFORE anything moves.

Three rules make the number mean something:

  1. Per key, not per substring — the split above. Content is reported in its own historical content — a record of what was said and done, left by design section, next to the events log, and never counts toward the exit code. On a busy install it is normally the far larger number, and that is fine.
  2. A name is not state, a cwd is. A Claude project directory is stale when its name is no longer the encoding of its own cwd, or when that cwd no longer exists — not because the name still spells a pre-rename path. A channel whose custom workDir really is ~/Slack Agent/<slug> was deliberately left in place, so the directory encoding that cwd is exactly right.
  3. After the move, a path that still resolves is correct. A state field naming a pre-rename path that still exists on disk points at a real directory that did not move. Those are listed under still on disk — deliberately not moved, so the recorded path is correct with the stores that reference them, so the reason the count is zero is visible rather than assumed. Before the move this exoneration is switched off — nothing has moved yet, so everything is billed, which is what makes the same command work as a pre-flight.

So a non-zero exit always means there is something to repair, and --repath is the repair.

--repath

node scripts/migrate-channelgate.mjs --dry-run --repath   # preview
node scripts/migrate-channelgate.mjs --repath             # apply

Every rewrite pass the migration runs — database blobs and typed columns, config JSON, Claude projects (including directory renames) and engine home, Codex index/rollouts/config.toml, work-folder text, service definitions, and the per-channel lockdown regeneration — applied against the current roots. It moves nothing: not the runtime root, not the workspace, not a single channel folder. Use it when the one-time migration could not cover everything: a host somebody moved by hand, a store that was locked when the boot migration went through, a channel folder an operator relocated themselves.

Unlike the one-time migration, which drops every content-addressed run cache under channels/**/runtime/ (at migration time all of them name the old roots), a repath drops only a cache that actually still mentions one — otherwise every run would cold-start a healthy install.

It prints the same plan and the same summary as a migration, and ends with the audit (audit (post-repath)), so a run is its own acceptance check. It is idempotent — the rules are keyed on the pre-rename roots and on the flat channels/<slug> layout, so a store already on the new paths matches nothing and a second run reports zero and rewrites no byte. It refuses while the daemon is running for exactly the reason the migration does (it rewrites the same live stores); --dry-run reports the blocker and previews anyway, writing nothing.

--from <old> --to <new> — a folder you moved by hand

node scripts/migrate-channelgate.mjs --dry-run --repath --from /old/place --to /new/place   # preview
node scripts/migrate-channelgate.mjs --repath --from /old/place --to /new/place             # apply
node scripts/migrate-channelgate.mjs --verify --from /old/place --to /new/place             # count

The rename rules only know the roots the product renamed. A folder you moved — the daemon's own checkout (~/Code/claude-gateway~/Code/channelgate), a channel's custom workDir, a project directory — is invisible to them, so hand the pair over and it becomes one more rewrite rule for every pass above: the channel record, Claude project directories (renamed to the new cwd's encoding, which is what keeps -r resume working) and their transcripts, the engine home's .claude.json, Codex config.toml/index/rollouts, the moved folder's own MEMORY.md/memory/*.md, the installed service definition (WorkingDirectory), and the per-channel settings regeneration. Repeat the two flags for several folders. Move the folder FIRST, then repath: the rule says where the folder is, and the prose is rewritten there. Both sides must be absolute and distinct, and the new path may not lie under the old one (that rule would rewrite its own output on every run). A malformed or misplaced pair (without --repath/--verify) exits 2 with usage before anything is read. --verify handed the same pairs counts what still names the old side, so the acceptance check is the same command.

Reloading the service after the migration

The migration rewrites the installed service definition, but the running service holds a CACHED copy: systemctl --user daemon-reload is required before the next start reads the new log paths. The migration cannot do this itself (it runs inside the service it would tear down), so it leaves <runtime root>/service-reload-required.json and scripts/update-runner.mjs consumes it on the next restart: daemon-reload (in the unit's own scope) before the restart signal. Restarting by hand? Run the command the migration logged first.

--dry-run deliberately bypasses these checks and reports them instead — previewing the plan while the daemon is up is exactly when an operator wants it. The finding is printed as the first plan line (busy: … — a real run would refuse right now), the old database is opened read-only, and neither new root is created.

On refusal or failure the boot continues on the OLD paths. The process pins CHANNELGATE_DIR and CG_WORKSPACE_DIR back to the pre-rename roots for its own lifetime — an env override, not a symlink, so nothing on disk pretends the move happened. Clear the blocker and restart, or run the script by hand.

Preview with node scripts/migrate-channelgate.mjs --dry-run, which prints the same plan and writes nothing. Channels with a custom workDir are never moved, an existing destination is never clobbered (it is skipped and reported), a cross-device move copies and verifies before removing the source, and each old location keeps a MOVED.md breadcrumb. Every channel's .claude/settings.json is regenerated afterwards so nothing in it names a pre-rename path.

After migrating, re-run the service installer so the systemd unit matches the new name (channelgate.service); the installer removes the pre-rename one first so an upgraded host never runs two daemons against one runtime root.

Container runtime

This is the runtime — required, Linux-only. Every channel's engine processes run inside a long-lived container of that channel's own: its own HOME volume, its own CLI logins, its own process namespace, and none of the host's files reachable beyond the channel's work folder. There is no host fallback: the daemon refuses to boot without a usable container CLI, and a channel that cannot get its container (no built image, no engine login) fails the turn closed with the remedy named rather than running anywhere else. The daemon itself still runs on the host as an ordinary service; only the engines are containerized.

Prerequisites. Rootless Podman is the supported runtime (Docker works and is probed as a fallback, but rootless podman is what the uid story is built on). On Debian/Ubuntu:

sudo apt install podman uidmap
grep "^$USER:" /etc/subuid /etc/subgid    # must return a range for the DAEMON user
podman info --format '{{.Host.Security.Rootless}}'   # must be true

uidmap (the newuidmap/newgidmap helpers) and the /etc/subuid + /etc/subgid ranges are what make --userns=keep-id work; without them files written inside a container come back owned by the wrong uid on the host. Add the ranges with sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 <daemon-user> and re-run podman system migrate if podman had already been used. Everything below is run as the user the daemon runs as — a rootless store belongs to one user, and two gateways on one host (each under its own account) get entirely separate stores.

Build the image. Nothing runs until the channel image exists; the daemon never builds one inside a turn, because a build takes minutes and would look like a hung answer. npm run setup builds it as part of a fresh install (skip with --skip-image / CG_BUILD_IMAGE=no); by hand:

npm run build:image                        # channelgate/runtime:<spec version> + :latest
npm run build:image -- --cli docker        # force a CLI
npm run build:image -- --no-cache

The build bakes the daemon user's real uid/gid into the image's agent user, so build it as that user — building as someone else produces an image whose channels cannot write their own workdir, and building as root is refused outright. CLI versions come from containers/versions.json; bump a pin, rebuild, and each channel picks the new image up on its next turn (the container fingerprint follows the resolved image ID, not the moving tag).

A self-update rebuilds it for you. The transactional updater runs the build itself, after dependencies and before the restart, whenever the candidate changed anything under containers/, bumped imageSpecVersion, or no image is built at all — so a /update no longer leaves every container channel on the previous toolchain. The build is the one step that never blocks: if it fails, the update reports channel image build failed — run \npm run build:image`and carries on to the restart, because the image already on disk keeps running every container channel. A manualgit pullstill needs a manual rebuild; the daemon compares the built image'scg.image.versionlabel against the spec this checkout expects and logsthe built image is spec X but this checkout expects Y` at boot when they differ. An older image still runs — it is just missing whatever the newer spec added. After a rolled-back update the same line can appear the other way round (the image is NEWER than the restored checkout); that image runs too, and the next successful update settles it.

Settings → Container runtime (admin UI):

Setting Meaning
Container CLI auto (podman, then docker), or force one
Image reference default channelgate/runtime:latest
Stop an idle channel container after minutes, default 10
Max containers running at once default 8; past it the least-recently-used idle container is stopped
Process / memory / CPU limit --pids-limit (default 1024), --memory (e.g. 2g), --cpus (e.g. 1.5); blank = no limit
Claude token for container runs the output of claude setup-token on the gateway host — write-only
Full-access channels see the gateway home off by default; on = every Full-access channel's container also mounts the gateway user's whole home read-write (see below)

Values that would reach the container CLI's argv are validated at the boundary: a flag, a space or a shell metacharacter in the image/memory/cpu fields is rejected with an error, not silently cleaned.

Engine logins. By default a container Claude run is handed a RELAY of the host user's own Claude login — its current access token, in CLAUDE_CODE_OAUTH_TOKEN — so keeping claude signed in on the host is all a channel needs. Nothing is copied or mounted. Optionally run claude setup-token on the gateway host and paste the value into Claude token for container runs: that token is then used instead and never needs refreshing. Codex is different — it rewrites auth.json in place, so every container shares a read-write mount of the gateway's real auth file; keep the host signed in with codex login. Codex sessions and history are still per channel.

Network. Every channel container runs on the default bridge network. The per-channel Allow network switch (admin UI → the channel → Advanced, or set_channel_network in chat) is kept and shown: it tells the engines whether the channel is meant to have network access (Codex read mode refuses network on its own), and that is all it does in this release — there is no per-domain filtering and no egress cut-off in the container. The boundary today is the container's filesystem and process isolation, not its egress; a container-side egress proxy that enforces the switch is the planned follow-up.

Admin channels run in containers too. An admin author's live turn adds the engine's bypass flag; the channel's work folder is bind-mounted read-write like any other's. An admin channel whose work folder is a host directory — the gateway's own checkout, say — hands that directory, and only that directory, to its container: everything in it is visible there (the checkout's .env included), nothing beside it is. That is the intended trust model for admin channels; put nothing in such a folder that the channel must not see.

Full-access channels see the gateway home (Settings → Container runtime, off by default) is the one deliberate widening. While it is on, every channel in Full access also gets the gateway user's whole home directory bind-mounted read-write at its identical path inside its container — every channel's work folder and memory, every repo under that home, the gateway root with its logs and per-channel metadata, and its credential stores (~/.claude, ~/.codex, ~/.ssh, config/, gateway.db). Only the container engine's own storage (~/.local/share/containers) is masked, because a write into a running container's layers corrupts it. Use it for an overseer channel that must see every agent and every repository; leave it off everywhere else. It is a boolean, never a path — the grant is the daemon user's home and nothing else — and it is per channel: every author the channel admits can read the home through the engine's file tools (an admin author's turn can also write, with the bypass tools). Flipping the switch, or moving a channel in or out of Full access, changes the container's create-time fingerprint, so its container is recreated at the next turn; the HOME volume survives. Other Linux users' homes stay unreadable (there is no sudo in the image), and the host's system directories are the image's own.

Where things live. Containers and the per-channel HOME volumes live in the rootless podman store under the daemon user's home — ~/.local/share/containers by default; podman info --format '{{.Store.VolumePath}}' prints the exact path. The channel's work directory is never a volume: it is the same ~/ChannelGate/<platform>/<slug> (or the channel's custom workdir) bind-mounted at the identical absolute path, so host tooling and VS Code see the agent's files instantly. Per-run engine-facing files (the settings copy, the MCP config, plugin dirs, job logs) live in ~/ChannelGate/.runtime/<platform>/<slug>, also bind-mounted at the identical path. Nothing under ~/.channelgate/ is mounted except the channel's clean workspace and the read-only control-socket directory.

What persists where. The short version: everything a channel accumulates survives, and only running processes do not. A channel container is stopped routinely — after containerIdleMinutes of quiet, and to make room under containerMaxRunning — and recreated whenever its create-time configuration changes (an image rebuild, a limit change). Neither loses data.

Where Holds Survives a stop/start Survives a rm + recreate
Per-channel HOME volume (/home/agent) engine sessions and transcripts, CLI logins (gh, vercel, supabase, MCP auth), npm -g, pip --user/pipx/uv/cargo installs, caches, dotfiles yes yes — the daemon removes a HOME volume only when the CHANNEL is deleted, never on a rollback, a reconfiguration or an image bump
/tmp and /var/tmp (bind mounts of ~/ChannelGate/.runtime/<platform>/<slug>/{tmp,var-tmp}) scratch files, Claude Code's per-session scratchpad, anything an agent parks between turns yes yes
Channel work directory (~/ChannelGate/<platform>/<slug>, bind mount) the project itself yes — it is a host directory yes
Per-run artifacts (~/ChannelGate/.runtime/<platform>/<slug>, bind mount) this run's settings copy, MCP config, job logs yes yes
Engine session history (Claude transcripts, Codex rollouts, subagent transcripts) inside the HOME volume yes yes
/run (tmpfs, 64m, noexec) run-helper pid files, the read-only control socket no — fresh on every start, deliberately no
Image layers (/usr, /opt/channelgate, the pinned engines) read-only and root-owned yes replaced by an image rebuild
Foreground/warm engine processes no — a stop kills them; the turn replays no
Detached background jobs no — a stop kills them, and the next turn says so no

/tmp and /var/tmp were tmpfs until image spec 1.1.0, which meant the idle reaper's routine stop emptied them ten minutes after every turn. They are host directories now, so they keep their contents — and the size cap that came with tmpfs is gone with it: they grow against the disk, just like the channel's work directory. Both are visible on the host under ~/ChannelGate/.runtime/<platform>/<slug>/, so an operator can see (and, if a channel ever hoards, clear) what an agent parked there. Nothing in the daemon deletes them.

Installing tools in a channel. An agent can install whatever it needs, and it stays installed — everything below writes inside the per-channel HOME volume:

How Lands in Notes
npm install -g <pkg> ~/.npm-global/bin NPM_CONFIG_PREFIX; first on PATH, so a channel's own version wins over the image's
pip install <pkg> ~/.local/bin PIP_USER=1 + PIP_BREAK_SYSTEM_PACKAGES=1, so Debian's externally-managed-environment refusal never appears
pipx install <app> ~/.local/pipx, shims in ~/.local/bin the right choice for a Python CLI with its own dependencies
shell installers piped from curl (uv, rustup, bun, deno, …) ~/.local/bin, ~/.cargo/bin, ~/.bun/bin, ~/.deno/bin all four are on the image PATH
anything hand-dropped in ~/bin ~/bin on the image PATH

Codex runs its shell commands through a login shell (bash -lc), and Debian's /etc/profile resets PATH on the way in; the image re-asserts its PATH from /etc/profile.d/channelgate-path.sh (spec 1.1.1), so both engines see the same install dirs. In a Codex shell ~/.local/bin and ~/bin end up ahead of ~/.npm-global/bin (Debian's ~/.profile prepends them once more) — a pip-installed CLI with the same name as an npm one wins there, and the npm one wins in a Claude shell. | apt install | — | not available: there is no sudo and the toolchain is root-owned by design, so a channel cannot replace its own engines. Ask the operator to add the package to containers/Containerfile and rebuild |

Inside a hand-made virtualenv, pip install needs PIP_USER=0 (a --user install is not possible in a venv); pipx handles this for itself. If a CLI installs but the shell cannot find it, the container is running an image built before spec 1.1.0 widened the PATH — rebuild with npm run build:image; the daemon logs that mismatch at boot.

One runtime, one home for a thread's history. A thread's engine-native history (Claude's projects/<cwd-key>/<id>.jsonl plus its <id>/ subagent directory, Codex's sessions/YYYY/MM/DD/rollout-*-<id>.jsonl) lives in the channel's HOME volume and is never moved: stopping, starting or recreating the container loses nothing, and there is no other backend for a thread to change to. A thread whose last turn ran on the host before the container-only switch has no session on the container side, so its next message falls back to the existing heal — a fresh engine session with the chat transcript replayed, which keeps the conversation readable but not the engine's own working state (compaction summaries, tool results, subagent transcripts).

Inspect and debug.

Attach VS Code to the same channel container. Install VS Code's Dev Containers extension; when the gateway uses Podman, set dev.containers.dockerPath to podman. From the gateway checkout as the daemon's Linux user, run:

npm run vscode -- <channel-id-or-slug-or-exact-name>

The helper starts the managed container if necessary and opens the channel's real mounted workdir, not a newly created dev container. Keep the helper terminal open: code --wait holds a signed editor lease until that VS Code window closes, preventing idle or capacity eviction. The attached terminal is user agent with the same persistent /home/agent, so its installed tools, GitHub and provider CLI logins, Claude history, and Codex history are the channel's own existing state.

Codex uses the same shared login file already mounted for chat turns. Claude's rotating credential file is still never copied or mounted: the helper refreshes the gateway's normal subscription access-token relay every 20 minutes and exposes only that access token to interactive claude commands. Closing VS Code removes the live token and releases the lease; an interrupted helper is detected by PID start identity and its stale lease is discarded automatically. A daemon configured only with ANTHROPIC_API_KEY cannot currently export that key to an interactive editor terminal; use the normal operator subscription login or a configured claude setup-token for this workflow. The editor lease is held in daemon-owned metadata, outside the writable container artifacts.

This is deliberately an operator command, not a remotely callable channel tool: VS Code provides a full shell inside the container and bypasses chat tool presets. The container boundary remains the same—only this channel's mounts exist, there is no sudo, and no gateway database, host home, or other channel is exposed. Per-channel environment secrets remain write-only and are not exported to the editor terminal.

podman ps --filter label=channelgate=1                 # every ChannelGate container on this host
podman ps -a --filter label=cg.install=<install id>    # only THIS gateway's (see /api/health)
podman exec -it <container> bash                       # a shell in a channel, as the agent user
podman logs <container>                                # cg-init output
podman volume ls | grep -- -home                       # the per-channel HOME volumes

/api/health (authenticated) carries a containerRuntime block: the CLI kind, version and rootlessness, whether the image is present, the control socket, and every running container with its lease count and idle time. /status in a channel names its backend, container, image, state and uptime. An attached podman exec terminal inherits the channel's environment, not the daemon's.

Rollback. One lever: roll the release back through the transactional updater. It deletes nothing — HOME volumes are removed only when a channel is deleted, never on a rollback or a reconfiguration, and the two temp trees are host directories the daemon never touches, so the restored release finds every CLI login and every scratch file where it was left. There is no per-channel or gateway-wide "back to the host" switch: the container is the only place a turn runs.

Known caveats (v0.8 P1).

  • Per-user Codex skill grants are not delivered in containers. The per-run Codex skill overlay was built for a synthetic host HOME that a container does not have; a Codex run gets the channel's skills through the mounted workdir, but not that overlay.
  • Codex sessions are per channel, but the sign-in is shared. Every container mounts the same auth.json the gateway uses. A codex login on the host that replaces the file leaves a running container holding the old inode — /status and /api/health report the drift; restart the channel's container (or let the reaper stop it) to pick the new one up.
  • A relayed access token is readable by the channel's own agent. It rides the exec environment, so an agent in that channel can print it. It cannot rotate anything (an access token carries no refresh half) and it dies within hours, but it is a live credential for that window; the P3 egress proxy replaces it with an opaque token.
  • Egress is not policed per channel. Every container runs on the default bridge network, and the per-channel Allow network switch does not cut it — it only tells the engines whether the channel is meant to have network. The per-domain allow-list of the retired host sandbox has no container equivalent; the container-side egress proxy that will enforce the switch is a later slice. Because it is advisory, the switch is now STATED rather than inferred: the mode label carries it in both directions (Bash · network off), /mode and /status add "advisory — not enforced by the container yet", the gateway-managed block at the top of each conversation's CLAUDE.md tells the engine the same thing, and every run_config event records networkEnforced: false beside networkPolicy. NETWORK_POLICY_ENFORCED in src/engines/network-policy.js is the single flag to flip when the proxy lands.

Claude login in containers: with no containerClaudeOauthToken, each container Claude run receives a RELAY of the gateway's resolved login — normally the host user's own ~/.claude — as a current OAuth access token in CLAUDE_CODE_OAUTH_TOKEN (refreshed on the host first by a cheap haiku turn, in that login's own config dir, when under 30 minutes remain). The login file is never copied: Claude Code rotates refresh tokens, and a copy that refreshes logs the original out. A relayed access token cannot rotate anything. A claude setup-token value, when configured, is used instead and needs no refresh.

Service unit: the installer sets KillMode=mixed. systemd then sends SIGTERM only to the daemon, which drains, marks the shutdown and sweeps its own engine children (the container run groups) so interrupted execution is recorded for reconciliation on the next boot. With the default control-group mode systemd signals the engine (or the podman exec client) directly and the turn may be recorded as a plain error before orderly state persistence. Existing installs: add KillMode=mixed under [Service] and systemctl --user daemon-reload.

System health

Open System health, the last admin navigation item (/system-health). The host daemon reads Linux /proc and /sys without elevated privileges or subprocesses. It samples every five seconds even when no browser is open; browsers poll while visible and active. Pausing the page does not stop background collection. Refresh also rescans hardware. CPU uses counter deltas (the first sample is unknown), RAM uses MemAvailable, and load is the Linux 1/5/15-minute load average. Storage measures the filesystem containing the configured gateway runtime root, not the sum of disks in the hardware inventory. Used bytes exclude free blocks; available bytes exclude filesystem-reserved blocks; the usage percentage is used / (used + available), as for an unprivileged process. References: Linux proc documentation and Node filesystem statistics.

Resource minute averages and peaks stay for 30 days. Storage stays for 186 days (at least six calendar months); samples older than 30 days are reduced to hourly observations with preserved peaks. Responses reduce long resource series to about 240 buckets and storage to daily points. Empty history and missing metrics are reported without simulated values. Forecasts require at least seven days of measured history and use only the segment since the last observed capacity change. The estimate assumes continued growth at the fitted rate, not a guarantee of free space.

Hardware is read at startup, every five minutes and on refresh. The current snapshot and at most 100 change events are retained; no temperatures, serial numbers, MAC addresses or machine IDs are collected. DIMM information and some device details may be unavailable without host interfaces; the page reports unknowns. RAM/disks recognized by Linux appear at the next scan. Distribution changes appear on rescan; a newly installed kernel appears only after booting it. Hardware details remain behind the admin session and never appear on the public liveness endpoint.

Metrics use tables in the existing SQLite database. Retention applies only to these tables, not audit/usage records. Do not impose a 100 MiB limit on the entire gateway database or force global WAL truncation: other gateway records and readers share it. Measure incremental metrics storage using an isolated canary, with 100 MiB for its database plus WAL as a safety budget.

Before enabling on a served host, run the canary on that Linux host from the tested checkout:

node scripts/system-health-soak.mjs --output /absolute/new-health-soak-report.json --storage-path /path/to/gateway-runtime-root

It runs in the foreground for 24 hours, reads the target filesystem, and writes metrics only to a fresh temporary database. The JSON report updates every 30 seconds and records actual elapsed time, errors, gaps, CPU, peak RSS, database plus WAL bytes, hardware visibility and persistence after reopening. The report path must be new; the scratch database is removed at completion. Use the service manager or a daemon-owned job to keep it alive when a session cannot remain open. --duration-seconds 65 is a quick smoke; it explicitly cannot pass the 24-hour gate. A report with containerDetected: true describes the container's visible interfaces and is not evidence of a host deployment. Even when false, independently verify where the process ran.

Review the measured overhead rather than treating the prototype estimates as guarantees. After the host canary passes, use the normal serialized beta landing and safe restart procedure, log in, and verify current samples, hardware visibility and history persistence on the actual daemon.

Retention and log rotation

Run npm run maintenance daily from the service manager. CG_RETENTION_DAYS defaults to 30 and removes expired backup files; CG_MAX_LOG_BYTES defaults to 10 MiB and retains one rotated copy. Active logs are rotated by copy-truncate, not rename: systemd holds the daemon's stdout/stderr file descriptor open, so a renamed file would keep growing under its new name and the size cap would never apply to the live log again. Console output is centrally redacted for Slack/OpenAI-style tokens, bearer values, and secret query parameters before systemd (or the journal) receives it. Database audit/usage retention is deliberately not automated: legal and operational owners must define it before enabling destructive row pruning.

Upgrade and rollback canary

The transactional updater's phases are: preflight → snapshot → checkout → install → audit → test → provision → channel image → restart → verify, with an automatic rollback (restore → install → restart → verify) on any failure up to the restart. The channel-image phase is the single exception to that rollback: it only runs when the image is provably behind this revision, and a failure there is reported and stepped over rather than rolled back (see Container runtime).

Before promotion: run static checks, the complete suite, npm run backup, npm run restore:drill, then npm run release:artifacts. Deploy one canary service, verify /api/health, Slack mention/DM, both enabled engines, approval gating, and backup restore. The transactional updater automatically returns Git/dependencies to the prior revision after a failed health check; runtime data snapshots are never restored automatically. Keep the previous release artifact and backup until the canary has run for 24 hours.

The admin Restart button picks its exit code from the detected service manager (nonzero under the unit's Restart=on-failure, clean 0 for an unmanaged foreground run). Nothing in the test suite can prove a real systemd relaunch — that stays a manual canary step, and a follow-up if these paths ever get an automated harness: press Restart on the canary and confirm the daemon comes back with a new instance id.

License keys

The daemon verifies its license key at boot and about every 24 hours, and reports usage counts daily and once at shutdown. Both are fire-and-forget: neither can delay a boot, a turn, or an exit, and neither can kill a run. Full model in LICENSE-KEYS.md; exact payloads in PRIVACY-AND-DATA-FLOW.md.

Day-to-day. Settings → License shows the tier, the key's last four characters, the last verification, the next scheduled check, a banner for any non-quiet state, and this month's per-conversation usage against the limit. Verify now forces a check. In chat, get_license_status answers the same question for anyone allowed in the channel.

What each state means for an operator.

Banner What happened What to do
(none) valid, or no_key on a deliberately unlicensed install nothing
offline grace the platform has been unreachable for under 14 days; the last verified tier still applies check egress to CHANNELGATE_PLATFORM_URL; it will heal itself when the network does
grace expired unreachable for over 14 days the tier is kept until the start of the next UTC month, then the no-key limits apply — fix connectivity before then, or ask for an offline payload
key rejected / revoked the platform returned 401 / 403 the no-key limits are already in force; install a valid key

Egress. The only host the daemon itself contacts is CHANNELGATE_PLATFORM_URL (https://channelgate.dev by default) over HTTPS, with a 10-second timeout. A deployment that must originate no outbound traffic at all runs on CHANNELGATE_LICENSE_PAYLOAD (an offline signed license) and makes no request.

Backups. The cached verification and the installation id live in _meta in gateway.db, and this month's counters in license_usage — both are covered by the normal encrypted config backup. Restoring an older database restores an older cached verification; the next check refreshes it. Deleting the _meta installation-id row simply mints a new random one.

Troubleshooting a refused conversation. A refusal is always a visible reply in the thread, and it is also logged: license_run_refused in the event log carries the conversation, the reason (conversation_limit or monthly_cap), the UTC month, and the run count. Cross-check it against Settings → License → Usage this month.

Verifying and recovering a container toolchain update

The image carries a digest of its container build sources and its pinned CLI versions. Update compares this with the desired checkout even when no Git revision changed. A failed image build is retried by the next Update; a zero exit with stale labels is reported as a failure. Custom image references are operator-managed and are never silently replaced with the default image.

Container runtime status displays built/desired Claude and Codex versions, whether a rebuild is needed, and how many existing containers await adoption. Active turns keep their image until idle. Update results retain image-build warnings even when the daemon update succeeds.

Update smoke checks use a disposable channel container and the same Claude/Codex runners as live turns. Each configured engine must return CG_UPDATE_SMOKE_OK; absent logins are explicit skips, and zero tested engines fails. No smoke engine runs on the host. The fixture's container, HOME volume and working files are removed afterwards. An expired operator Claude token must be refreshed before updating; the smoke probe does not launch a host token-refresh turn.