feat(docker): run the gateway in a container - #127
Open
yoobi wants to merge 6 commits into
Open
Conversation
added 6 commits
July 31, 2026 17:39
Claude Code moves its user-scope write root when CLAUDE_CONFIG_DIR is set, but
five call sites hardcoded ~/.claude, so setting it silently broke credentials
(0 models), transcript recovery ("no recoverable transcript") and skills.
Adds resolveClaudeConfigDir() to shared/home.ts beside resolveJinnHome() and
routes all five through it. Unset, behaviour is unchanged.
Also renames seedTrust's claudeJsonPath parameter, which now shadowed the
exported function of the same name in its own module.
staleGatewayPids filtered only pid !== currentPid, and gateway.json survives an ungraceful stop. A container starts pids from 1, so the reaper SIGTERMs numbers that now belong to unrelated live processes — possibly PID 1. Records os.hostname() with the pids and skips reaping when it differs; under Docker that is the container id, so a recreate no longer matches. Info written before this change has no namespace and is treated as foreign, so the first restart after upgrading reaps nothing. A host reboot still recycles pids under one hostname; narrowing that needs non-portable process-identity checks.
The engine spawns claude with --dangerously-skip-permissions on every turn, which disables the approval gate for everything it can reach; containerising makes that the mounted paths rather than a whole home directory. Multi-stage build running the ROOT pnpm build, which also syncs the dashboard the gateway serves. It ships only what jinn cannot run without, including the curl every agent is told to call the gateway with, and a pinned claude-code with its auto-updater off. The entrypoint rebinds gateway.host off loopback, which inside a container binds nothing reachable, and refuses to rewrite a config that disabled auth into a gateway that will not start. A command passed to docker run replaces the gateway instead of being appended to `jinn start`, which would boot a second one against the same volume. CLAUDE_CONFIG_DIR puts Claude Code's config on a volume, and auth is mandatory once bound to 0.0.0.0, so the flow ends in `jinn pair`. Three seams this exposed are fixed for every install: seedTrust records the Bypass Permissions consent that onboarding implied up to claude-code 2.1.170 and no longer does; transcript reads, the Claude auth guard and the engine child env all follow CLAUDE_CONFIG_DIR; and stale pids are keyed on hostname plus boot identity, which a reboot changes and a hostname does not. CI boots the built image and checks the native addons, a PTY spawn and /api/status.
loadConfig() resolves JINN_HOST/JINN_PORT into every config it returns, and three writers persisted that back to the jinn-home volume — a workstation later opening it would silently bind every interface. saveConfigAtomic restores the file's own values instead, so no writer can leak them. A new workspace no longer inherits this gateway's port, fails to start, and deletes itself on the way out. "Where is this gateway reachable" had five answers; one resolver replaces them, the gateway records its own URL for the healthcheck to read, and `jinn list` judges each instance against its own endpoint rather than the ambient one. The entrypoint whitelists the single `jinn start` flag that is safe in a container.
Three conflicts, all places where main and this branch edited the same lines. Two were import lists that keep both sides (api.ts gains messageBodyError, workflow.ts gains the workflow-issues helpers). The README roadmap takes main's shorter list minus the Docker item, which this branch ships.
POST /api/instances read gateway.host straight off disk, which bypassed ApiContext.getConfig() — the handler's only supported config source, and the seam the test stubs — so it saw no config.yaml under the test home and passed undefined. It also read the ambient home rather than the one the context describes. The environment is the only thing that injects a host into the in-memory config, so comparing against JINN_HOST answers "did the user choose this?" without any I/O. A new test covers the container case, which nothing exercised before. Windows: config-document's owner-only assertion now goes through expectPosixMode, which the repo added for exactly this — Windows reports 0o666 for a 0600 file.
Author
|
@hristo2612 whenever you have the time. My proposition to support docker :) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
The engine spawns
claudewith--dangerously-skip-permissionson every turn, sothe approval gate is off for everything that process can reach. On a host install
that is a whole home directory; in a container it is only the paths you chose to
mount.
docker-compose.ymlkeeps that list in one place and labels it the blastradius.
What it adds
Dockerfile,docker-compose.yml, an entrypoint, a HEALTHCHECK, anddocs/docker.md. The build is multi-stage and runs the ROOTpnpm build, whichalso syncs the dashboard the gateway serves — filtering to the jinn package yields
a gateway with no UI.
pnpm deploythen ships a runtime with no build toolchainin it: 215MB against the 650MB of workspace tree it replaces.
claude-codeispinned by build arg with its auto-updater off, because engine drift is a realised
failure mode.
The published port is loopback-only, and the gateway refuses to serve a network
bind without auth, so first-run ends in
jinn pair.Three seams it exposed, fixed for every install
seedTrustnow records the Bypass Permissions consent that global onboardingimplied up to claude-code 2.1.170 and no longer does. Without it every turn hangs
on a dialog no PTY can answer.
CLAUDE_CONFIG_DIR; five call sites hardcoded~/.claude.from 1, so the reaper was SIGTERMing unrelated live processes — possibly PID 1.
The invariant worth reviewing
JINN_HOST/JINN_PORTdescribe this process's binding, andconfig.yamlsits ona volume that outlives the container.
loadConfig()resolves them into everyconfig it returns, so the hazard is a writer persisting them: a home later opened
on a workstation would silently bind every interface.
saveConfigAtomicrestoresthe file's own values, so no writer can leak them — that is the single place to
check if you add another.
Related consolidation: "where is this gateway reachable" is one resolver instead of
five copies that had drifted apart, and the gateway records its own URL so the
shell healthcheck does not re-derive wildcard and IPv6 handling.
Verification
The
dockerCI job builds the image, then loads better-sqlite3 and spawns a PTY —neither of which
docker buildexercises. It boots the gateway on an empty volumeand asserts
/api/status, the dashboard actually served at/,HEALTHCHECKreaching healthy, and recovery from a stale
gateway.pid.