Operations
Backup and restore
Section titled “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
Section titled “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
Section titled “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 proxyis deliberately NOT enabled: it would also re-pointreq.secure/req.protocol/req.hostnameat client-supplied headers (the Host/Origin guard and the session cookie’sSecureflag read those headers themselves, deliberately), and it knows nothing aboutCF-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=1in.envto honourX-Forwarded-Forthere — 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-Forrather than append to a client-supplied one. Where it appends (Cloudflare does),CF-Connecting-IPis 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
Section titled “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
Section titled “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
Section titled “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
Section titled “--verify”node scripts/migrate-channelgate.mjs --verifyA 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:
- 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 designsection, next to theeventslog, and never counts toward the exit code. On a busy install it is normally the far larger number, and that is fine. - 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
workDirreally is~/Slack Agent/<slug>was deliberately left in place, so the directory encoding that cwd is exactly right. - 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 correctwith 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
Section titled “--repath”node scripts/migrate-channelgate.mjs --dry-run --repath # previewnode scripts/migrate-channelgate.mjs --repath # applyEvery 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
Section titled “--from <old> --to <new> — a folder you moved by hand”node scripts/migrate-channelgate.mjs --dry-run --repath --from /old/place --to /new/place # previewnode scripts/migrate-channelgate.mjs --repath --from /old/place --to /new/place # applynode scripts/migrate-channelgate.mjs --verify --from /old/place --to /new/place # countThe 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
Section titled “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
Section titled “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 uidmapgrep "^$USER:" /etc/subuid /etc/subgid # must return a range for the DAEMON userpodman info --format '{{.Host.Security.Rootless}}' # must be trueuidmap (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> + :latestnpm run build:image -- --cli docker # force a CLInpm run build:image -- --no-cacheThe 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 checks the image against the checkout’s expected version and source digest. Build or
executable-check failures leave an explicit image warning while the host daemon can still restart.
New container runs fail closed when the image is missing or stale; an existing image is not a
promise that channels remain usable. Run npm run build:image to repair it, or retry Update / Repair
(the build is retried even when Git is already current). Active containers retain their current
image until idle. A manual git pull still needs a matching image rebuild.
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 = current admin authors in Admin channels mount the gateway user’s home; members keep a separate project container (see below) |
| Legacy open network (no egress proxy) | off by default (containerEgressMode = "proxy"); on ("bridge") = the pre-proxy behavior: the open bridge network and raw secret values (see Network below) |
| Withhold readable variables | containerEgressSecretsStrict: ON for a new install, OFF for an install that existed before this default (stored once at boot; your choice is never changed); on = a secret with no egress rule is not given to proxy-mode containers at all, off = it is injected raw and listed as unprotected |
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 RELAYED the same way behind
the egress proxy (see Codex login relay below): keep the host signed in with codex login;
nothing is mounted. Only the legacy open-network mode still bind-mounts
the selected auth.json read-write into a container (Codex rewrites it in place, so a
copy would fork the refresh chain). Codex sessions and history are per channel either way.
Network (the egress proxy). Every channel container runs with --network none: its only
interface is lo. The one way out is the daemon’s egress proxy. cg-init starts a small forwarder
(/opt/channelgate/bin/cg-egress.mjs) on 127.0.0.1:3128 inside the container, every HTTP(S)
client there is pointed at it (HTTP_PROXY/HTTPS_PROXY and the lowercase twins, NO_PROXY for
loopback only, NODE_USE_ENV_PROXY=1), and the forwarder pipes each connection to the channel’s
own unix socket — ~/.channelgate/eg/<12 hex>/egress.sock, mounted read-only at
/run/channelgate/egress/. The socket path IS the channel’s identity: the proxy never trusts what
the container says about itself, and no container can see another channel’s socket. The proxy
terminates TLS with the deployment’s egress CA (created once under
~/.channelgate/config/egress-ca/, ca.key 0600 never leaves the daemon) and applies the
channel’s policy to every request:
- Allow network off: only the engine endpoints (
api.anthropic.com,claude.ai,statsig.anthropic.com,api.openai.com,chatgpt.com,auth.openai.com, a configured Qwen endpoint) and the remote MCP servers this channel’s runs were handed are reachable; anything else answers403 {"error":"network-off"}. - Allow network on: any PUBLIC destination. Loopback, private, link-local (the cloud metadata address included), CGNAT and reserved ranges are always refused, checked on every resolved address and pinned for the connect, so DNS rebinding does not help.
- The switch is read on every request: a flip applies to the next connection, no recreate.
- Raw sockets (
ssh,psql) get a plain CONNECT tunnel only while the network is on and only togithub.com:22plus the hosts an admin lists in the channel’segressRawHosts(admin API:PUT /api/channels/<id>/metawith{"egressRawHosts":["db.example.com"]}), on ports 22, 5432 and 6543. A client has to be pointed at the proxy for that (aProxyCommand); nothing else leaves the container. - A channel that genuinely needs arbitrary raw sockets can be given the open bridge BESIDE the
proxy:
{"rawNetwork": true}on the same admin API. The proxy env stays set, so proxy-aware tools still use placeholders, but the switch is then advisory for that channel and every surface says so. Changing it recreates that channel’s container at its next idle moment.
The CA trust bundle. At boot the daemon writes ~/.channelgate/run/egress-ca.pem (0644): the
host’s own /etc/ssl/certs/ca-certificates.crt followed by the egress CA, rewritten in place only
when its bytes change. It is mounted at /run/channelgate/egress-ca.pem and named by
NODE_EXTRA_CA_CERTS, SSL_CERT_FILE, REQUESTS_CA_BUNDLE, CURL_CA_BUNDLE, GIT_SSL_CAINFO,
PIP_CERT, NPM_CONFIG_CAFILE, CARGO_HTTP_CAINFO, AWS_CA_BUNDLE and DENO_CERT; Chromium
(agent-browser) gets --proxy-server plus the CA’s SPKI pin through AGENT_BROWSER_ARGS. A tool
that reads none of these fails TLS with an unknown-issuer error — point it at the bundle. Rotating
the CA means deleting config/egress-ca/ and restarting; every container picks the new bundle up
at its next start.
Secrets through the proxy. A proxy-mode container never holds a channel, organization or
personal secret that has an egress rule, nor the relayed Claude login: it holds a placeholder
(cgph_…; the Claude token keeps its sk-ant-oat01- shape). The proxy swaps the placeholder for
the real value only on that secret’s declared hosts and headers — GitHub, Vercel, Supabase, Make
and Composio token names have built-in rules; any other secret is protected once an admin fills
Used on hosts in the secrets editor (or passes hosts to set_secret) — and only while the
channel has live work (a turn, a background job, a memory review or an SSH session); a personal
secret additionally only while its owner is the one working there and NO other person has live
work there — another author’s turn, background job or SSH session pauses it (403 another-author-active / another-person-ssh-session) until that work ends. Everything else about
a placeholder is forwarded unchanged: a placeholder sent to a host it was not declared for arrives
as the useless string it is. When declaring Used on hosts, avoid multi-tenant suffixes such as
*.vercel.app, *.herokuapp.com or *.github.io: a wildcard there covers every other customer’s
deployment too, and a container could have the real value swapped into a request to a site it
controls. Rotation is live (the proxy
resolves the current value per request); removing a secret revokes its placeholder, and re-adding
mints a new one. A secret with no rule is withheld entirely while Withhold readable variables
is on (the default on a new install; the run’s credential note names it as withheld), or injected
raw and listed as unprotected in the admin UI and the run’s own credential note while it is
off. Either way list_secrets ends with a Finding naming each such secret; declare its Used on
hosts (or hosts with set_secret) to give containers a placeholder instead.
A secret with no rule is decided by its kind. One that looks like a web API token is
hidden: containers get a placeholder the proxy swaps in a credential-like header or query parameter (Authorization, x-api-key, api_key=, token=…), but only
on servers an admin approved for it. The first time a program sends it to a new server, the proxy
refuses that request (403, nothing sent) and posts an approval card in the conversation’s active
thread: Use secret X on api.example.com? Who may click follows the secret’s scope — an admin
for the organization’s, anyone working in the conversation for its own, the owner for a personal
one (anyone eligible may Deny). The card never expires. Approving remembers the
server on the secret, for every later run, schedule and SSH session; it takes effect within five
seconds, so the agent just retries. No card is posted when there is no active thread (an SSH
session or a background job alone) — approve with allow_secret_host instead. The engines’ own
APIs are never approvable. A secret whose name or value shows it is used outside an HTTPS request —
a mail or database password, a connection string, a signing key — or that is configuration (an
…_ID, …_REPO, …_REGION) stays readable (raw, listed unprotected). set_secret_mode
overrides the decision either way.
SUPABASE_DB_PASSWORD and other raw-protocol passwords can never be swapped: they stay raw
(withheld under strict).
What the proxy refuses on the way. Inside a tunnel the request target must be a path (an
absolute-form GET https://other/… line is 400 absolute-form-in-tunnel); a Host header naming
another host than the one the connection was approved for is 403 host-mismatch (no domain
fronting through an allowed CDN host); a request with no Host gets the approved host and no
secret. A request that carried a swapped secret asks the upstream for an uncompressed answer, and
the real value is scrubbed back to the placeholder in response headers and in any readable body
(text, JSON, or no declared type) — a declared binary body, or one the upstream compresses anyway,
passes through unscrubbed. Deadlines: DNS 5 s (504 dns-timeout), at most 16 destination lookups
in flight per channel (503 too-many-lookups), 256 open connections per channel socket, connect
15 s, response headers 60 s after the request was sent (504 upstream-timeout). Upstream TLS is
always verified, against Node’s bundled roots plus the host’s /etc/ssl/certs/ca-certificates.crt.
After a daemon restart the running proxy-mode containers get their channel listeners back at
boot ([egress] restored N running container listener(s)), so a recovered background job, an
attached editor or a process left running from an SSH session keeps its network without waiting
for the channel’s next turn.
Audit. Every swap of a channel/organization/personal secret, every refusal, every blocked
destination and every raw tunnel is an egress event (names, hosts, reasons and byte counts —
never a value, a header or a placeholder). Ordinary requests, including the relayed Claude login’s
swap on every API call, are only counted; /api/health → containerRuntime.egress shows whether
the proxy is up and how many channel listeners it holds.
When the proxy is down. A boot that cannot load the CA or write the bundle logs one line
([egress] proxy unavailable (…)) and carries on; every proxy-mode run, background job and SSH
container then fails before spawning with egress proxy unavailable: … Restart the gateway to retry, or … set Settings → Container runtime → Egress to "bridge". A down proxy never silently opens the
bridge.
The legacy escape. Legacy open network (containerEgressMode = "bridge") restores the
pre-proxy behavior for the whole gateway: containers on the default bridge network, raw secret
values in their environment, the switch advisory again. It exists for a host that cannot run the
proxy; leave it off otherwise. Switching it recreates each container at its next idle moment.
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.
Admin channels can access the host home (Settings → Container runtime, off by default) widens only current organization admins’ runs in Admin/Full-access channels. Those runs get read-write access to the gateway user’s home at its identical path, including repositories, other channels, logs, metadata and credential stores. Container-engine storage stays masked; the grant does not provide host root access or the host’s process namespace.
Non-admin members and guests use a separate project container even when this switch is on. They keep Worker with the channel’s Auto/Lean options and cannot read outside the declared project mounts. The daemon checks the author’s current admin role at each spawn, including background work; revocation removes the grant on the next resolved run. Memory reviews always use the project container. Shared project files and channel memory remain visible to admitted members, so do not put admin-only data in that working folder.
Admin authors retain the legacy container name, HOME and artifact directory, even in Worker
channels or with the switch off. Project authors use a fresh cgp2- container and separate
HOME, /tmp, /var/tmp and .runtime-project-v2/<platform>/<slug> artifact directory.
This prevents old admin transcripts, logins and scratch files from becoming visible to project authors. Switching
between lanes starts a fresh engine session without importing admin history. Container
recreation preserves each lane’s own volumes. SSH access remains refused for a channel with
the optional Admin home grant enabled; this change does not extend that broker’s authority.
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> for admins and
~/ChannelGate/.runtime-project-v2/<platform>/<slug> for project authors, each 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, per-author-lane 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 (per-lane volumes <container>-tmp / <container>-vtmp) |
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> or ~/ChannelGate/.runtime-project-v2/<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 |
/tmp/codex-daemon-<uid> (tmpfs, 1m, 0700, owned by the agent user) |
Codex’s app-server socket, which its sandbox requires on its own user-owned mount | no — a socket needs nothing kept | 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 persist 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. Since 2026-09-27 they are per-channel podman volumes (<container>-tmp,
<container>-vtmp; inspect with podman volume inspect or podman exec <container> ls /tmp), not
host folders under ~/ChannelGate/.runtime/<platform>/<slug>/ — those were also visible inside the
container through the artifact mount, a mount alias Codex’s sandbox refuses. The daemon deletes
them only with the channel, like the HOME volume.
Optional VPN database service. npm run vpn provisions an operator-managed OpenVPN service
and isolated database extractor without granting tunnel privileges to ordinary channel containers.
See CHANNEL-VPN.md for protected profiles, channel Secrets, systemd persistence,
database-only routing and live acceptance requirements.
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.
Thread /sudo: deliberate direct-host execution. An organization admin may send the typed
message @agent /sudo or @agent /sudo on in a Slack channel thread (/sudo as message text in a
DM). This is an in-thread bot control, not the conversation-top-level Slack slash-command surface.
Subsequent messages in that thread run the selected engine directly
as the daemon OS user, with its host filesystem, process namespace, native HOME, installed commands
and network—not inside the channel container. Only current organization admins can change or use
the thread while sudo is enabled; other senders receive an explicit rejection before work starts.
Use /sudo status to inspect it and /sudo off to return to the normal container. Boundary changes
are refused while the thread has running or queued work. This is intentionally more powerful than
Admin channel mode or the operator-home mount: it removes the container process/filesystem boundary.
Treat it as root-equivalent to the daemon account and use it only in trusted operational threads.
The stored sudo flag is not authority. Slack ingress, the run orchestrator, and background launches recheck current admin status; the run API and channel metadata cannot select the host runtime. Host child environments still use ChannelGate’s reviewed allowlist rather than blindly copying all daemon secrets, but a process running as the daemon user can access anything that OS account can.
A thread’s history follows /sudo boundary changes. Claude history
(projects/<cwd-key>/<id>.jsonl plus its <id>/ subagent directory) and Codex rollouts
(sessions/YYYY/MM/DD/rollout-*-<id>.jsonl) normally live in the channel HOME volume. When sudo is
enabled, ChannelGate carries the active session container→host; when disabled, it carries it back.
If a copy is unavailable or fails, the existing transcript-healing path starts a fresh native
session while replaying the visible conversation. Stopping, starting or recreating a container
still preserves its HOME volume.
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.
The VS Code server itself is not downloaded into the channel. The image carries one read-only copy
of each pinned version (containers/versions.json → vscodeServers, currently 1.139.0 and
1.138.0) under /opt/channelgate/vscode-server, and every container start links it into
~/.vscode-server — the attach layout (bin/<commit>) and the Remote-SSH layout
(cli/servers/Stable-<commit>/server, code-<commit>) alike — so an editor attach costs the volume
a few kilobytes instead of ~650 MB. A client on a version that is not pinned downloads its own server
into the volume exactly as before. To bump: add the new release’s commit and VS Code’s published
sha256hash values from https://update.code.visualstudio.com/api/versions/commit:<commit>/server-linux-x64/stable
and …/cli-alpine-x64/stable, drop the oldest, and rebuild; a mismatched download fails the build.
A channel opts out with touch ~/.vscode-server/.cg-no-shared-server inside its container. Servers a
client downloaded into a volume before this are left where they are — they show up in the storage
report as reclaimable, and nothing deletes them automatically.
Container storage
Section titled “Container storage”Nothing reclaims container storage automatically, on purpose: every image build keeps the previous runtime image, the idle reaper stops a channel’s container without removing it, and a stopped container keeps the image it was created from. Check it with the report, which changes nothing:
npm run runtime:storage # what could be reclaimed, and why each item is kept or notnpm run runtime:storage -- --json # the same report as JSONnpm run runtime:storage -- --apply # remove exactly the lines the report marks `remove`Run it as the gateway’s own OS user (rootless container storage is per user). It removes a stopped
channel container only when it was created from a superseded image (the channel gets a fresh one
on the current image next time), never a running one; runtime images other than the current one,
the previous spec (--keep-previous <n>, default 1) and any still in use; untagged leftovers; and
anything named for another install only when every folder that install’s containers mounted is
gone — a finished test run, not a second live gateway sharing this account. A channel’s home volume
is never removed by it. The space estimate counts image layers once each. To make it routine,
schedule the report and read it; schedule --apply only if you have decided to.
Codex uses the channel’s selected login, relayed in proxy mode. 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.
Let developers SSH into a channel container (docs/SSH-ACCESS.md). Unlike the operator’s
local VS Code attach, this is for people WITHOUT a host account: a one-time root installer
(scripts/install-ssh-access.sh) creates a no-shell login account whose forced command hands
each connection to the daemon, which authorizes the registered key against the channel’s SSH
grant list and runs an unprivileged sshd -i inside the container on that stream. Keys and grants
are managed from chat (add_my_ssh_key, grant_channel_ssh, show_channel_ssh); a container
with a live session is never idle-stopped; sessions and refusals are in the Audit feed.
podman ps --filter label=channelgate=1 # every ChannelGate container on this hostpodman 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 userpodman logs <container> # cg-init outputpodman 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; the sign-in is mounted only outside the proxy. Under the
legacy open-network mode each container mounts its selected host
auth.json. Acodex loginon the host that replaces the file leaves a running container holding the old inode —/statusand/api/healthreport the drift; restart the channel’s container (or let the reaper stop it) to pick the new one up. Behind the proxy the login is relayed and a newcodex logintakes effect on the next turn. - The relayed Claude login is a placeholder in proxy mode. A proxy-mode container’s
CLAUDE_CODE_OAUTH_TOKENis the channel’s relay placeholder, swapped for the current access token only onapi.anthropic.comwhile the channel has live work. Under the legacy bridge mode it is the real access token again, readable by the channel’s own agent (it cannot rotate anything and dies within hours). A daemon that authenticates Claude with its ownANTHROPIC_API_KEYstill passes that key through raw — the proxy does not rewrite it. - Engine keys that still reach a proxy-mode container raw. A Qwen harness’s provider key
(
ANTHROPIC_AUTH_TOKENpointed at the provider) still reaches that engine as a real value. Codex logins stored inauth.json, including API-key logins, are relayed behind the proxy. - A self-hosted Qwen endpoint on a private address is refused in proxy mode. The proxy never connects to loopback, private, link-local or CGNAT addresses, and a configured Qwen base URL is no exception: point the harness at a public endpoint, or run that channel under the legacy bridge mode.
- Egress is policed per channel by the proxy; the switch is advisory only where the proxy is not
the network. Under
containerEgressMode = "bridge"or a channel’srawNetworkescape the container has the open bridge and Allow network only tells the engines the channel’s intent; every surface then says so —/modeand/statusadd “advisory — not enforced for this container”, the gateway-managed block at the top of eachCLAUDE.mdand the per-attempt note tell the engine, andrun_configrecordsnetworkEnforced: falsewithegress: "bridge"or"proxy+raw". In proxy moderun_configrecordsnetworkEnforced: true,egress: "proxy", and the names of any unprotected (egressUnprotected) or withheld (egressWithheld) secrets.networkEnforcedFor(target)insrc/engines/network-policy.jsis the one question every surface asks. - Codex’s sign-in is a placeholder in proxy mode. See Codex login relay below. The legacy
bridge mode still mounts the selected
auth.jsonfile because it has no proxy swap. - SSH and VS Code sessions run in the same
--network nonecontainer with the proxy env and hold placeholders like a turn (container-secrets P3,docs/SSH-ACCESS.md); SSH-Lforwards to external hosts do not work without the network.
Codex login relay (container-secrets P4). Behind the egress proxy a channel container never
sees the host’s Codex login. Before each Codex run (and when an SSH session is prepared) the gateway
writes /home/agent/.codex/auth.json into the channel’s own HOME volume: the access token is the
channel’s relay placeholder shaped like a JWT (the real token’s claims, cgph_r… in place of the
signature — the CLI reads the claims, the provider checks the signature), the refresh token is empty,
last_refresh is now. The proxy replaces that whole token with the host’s current access token in
the Authorization header on api.openai.com, chatgpt.com and auth.openai.com only, while the
channel has live work. The container can use the login but cannot renew, rotate or take it anywhere:
sent elsewhere, or from another channel, it is worthless; a refresh attempt from inside is rejected
by OpenAI (empty refresh token). The host’s login is renewed by the gateway itself: when its access
token (10-day lifetime) has less than 48 hours left, the next Codex run first spends one cheap
codex exec --ephemeral --ignore-user-config turn (gpt-5.6-luna, low effort) in the login’s own
CODEX_HOME — the engine home when it holds a login, else ~/.codex — exactly what your own shell
does. If the CLI declines to renew a still-valid token the gateway waits 6 hours before trying
again; a failed refresh logs [codex-relay] host refresh turn exited …. What to watch: a Codex turn
that fails with “This channel runs in a container, but … Run codex login” means the host is signed
out or its token expired and could not be renewed — run codex login on the host. The first Codex
turn after upgrading recreates each channel’s container once (the mount is gone from its
fingerprint), and cg-init deletes an old copied Codex login carrying a refresh token from the
volume. The legacy open-network mode keeps the shared read-write mount (and says so in /status).
A separate Codex login for one channel. In the admin channel editor, Runtime → Codex login source → This channel’s own login. The choice comes before the engine and model settings. Default gateway login shows the shared Slack, Claude and Codex status, with links to the gateway Slack/Claude settings and direct ChatGPT or API-key sign-in for shared Codex. Slack and Claude currently use the gateway connection regardless of the Codex choice. Choose This channel’s own login to show the dedicated Codex controls; channel connector overrides remain in MCP Connections and Environment tokens.
Use Sign in with ChatGPT to start a device code flow in the admin page; open the displayed link, enter its code, and wait for the status to show the completed ChatGPT login. Device code sign-in may first need enabling in ChatGPT security settings or workspace permissions. Alternatively, enter a Platform key in the private password field and choose Use API key. A successful sign-in selects and saves This channel’s login. The page shows only the method and sign-in status after completion; it never returns the key.
For a terminal fallback, the editor shows that channel’s Codex home directory on the gateway
host. Create it under the gateway service account and sign in there. For ChatGPT subscription
access, use CODEX_HOME=<displayed directory> codex login --device-auth and complete the browser code flow. For an OpenAI Platform API key, use
CODEX_HOME=<displayed directory> codex login --with-api-key, supplying the key on standard
input as prompted by the CLI. Never put the key on the command line, in Slack, or in the channel
folder. CODEX_HOME=<displayed directory> codex login status checks the selected method. OpenAI
bills API-key runs separately from a ChatGPT subscription. The channel login does not fall back
to the gateway login if it is missing or expired.
For a laptop login, OpenAI documents copying ~/.codex/auth.json to a headless host as a
fallback when device login is unavailable. Use an encrypted SSH transfer into the displayed
host directory with restrictive permissions and run Codex as the gateway service account so
the one host-side file can refresh. The file contains full credentials, including a refresh
token: do not upload it through Slack or the admin UI. Channel containers receive only a
channel-bound placeholder. A ChatGPT login is relayed as the JWT-shaped access token described
above; an API-key login is relayed as a separate placeholder swapped only in the Authorization
header at api.openai.com. The channel setting affects new Codex turns; it does not change
Claude authentication. An Admin channel with the optional full operator-home mount can read
host files under that mount, including the gateway’s runtime root; use ordinary project channels
for credential isolation.
Remote MCP relay (container-secrets P1). Composio (composio-user, composio-agent in token
mode), the MakeItFuture toolbox and the Make toolbox never reach a container with their token. The
engine’s MCP entry is the image’s socket bridge naming the server (CG_MCP_SERVICE=remote-mcp);
the run’s signed capability lists those names, and the daemon keeps the real URL and header in
memory (never on disk, never logged) for no longer than the capability’s lifetime — 6 hours for a
turn, 12 for an SSH session — and normally only while something uses it: a cold turn’s grant ends
with the turn, a warm Claude process’s when the process retires, an SSH session’s at its end, and a
person clearing their Composio or Toolbox token drops every grant minted for their runs at once. On a remote-mcp hello the daemon checks the claim and its own registration, dials the
service (https only; Streamable HTTP, falling back to HTTP+SSE on a 4xx) and relays the tools. What
an operator sees: the artifact dir’s cg-mcp-*.json and Codex’s run/cg-codex-secrets-*.json hold
no Composio or toolbox token (Codex’s bundle holds only the capability), and a relay that fails
shows in the engine’s MCP log as one fixed line — remote MCP is not authorized for this run (the
grant expired or was revoked, or the daemon restarted since the run started: in-memory
registrations do not survive a restart, so the next turn simply registers again), too many remote MCP connections for this run (more than 8 at once for one server on one grant) or remote MCP unavailable (the service could not be reached; the upstream error is deliberately not repeated,
and a failed tool call reads remote MCP request failed). the gateway service takes no arguments
means the channel image predates spec 1.6.0. A
COMPOSIO_MCP_URL/TOOLBOX_MCP_URL override pointing at plain http cannot be relayed: container
runs skip that server and say so in the thread. The relay removes the credential from the box; the
egress proxy (above) is what polices where the box can connect. It needs image spec 1.6.0
(npm run build:image), which also ships the egress forwarder. Direct-host /sudo
threads keep the old shape: the engine dials the service itself.
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
Section titled “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-rootIt 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
Section titled “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
Section titled “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
Section titled “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
Section titled “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 runs directly on the gateway host through bash scripts/update.sh. The managed button
starts it in an independent systemd user service so it survives the daemon restart. Provider
logins, token relays, quotas and engine responses are not installation prerequisites. Git safety,
recovery snapshots, exact dependency installation, security audit, regression checks, provisioning,
image drift/build checks, graceful restart and authenticated daemon readiness still apply.
The same complete repair runs when the checkout is already current. Node, Claude and Codex
executables are checked using --version in a temporary network-off, read-only container without
host mounts or credentials. Image build failures remain explicit warnings and are retried next time.
The restart signal drains active work: SIGUSR2 exits with the restart code for the shipped
Restart=on-failure unit; SIGTERM supports Restart=always and on-success.
Readiness allows ten minutes for large skill catalogs to reconcile. The isolated engine response
probe remains available as a separate diagnostic.