Skip to content

feat(docker): run the gateway in a container - #127

Open
yoobi wants to merge 6 commits into
hristo2612:mainfrom
yoobi:feat/docker-image
Open

feat(docker): run the gateway in a container#127
yoobi wants to merge 6 commits into
hristo2612:mainfrom
yoobi:feat/docker-image

Conversation

@yoobi

@yoobi yoobi commented Aug 4, 2026

Copy link
Copy Markdown

Why

The engine spawns claude with --dangerously-skip-permissions on every turn, so
the 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.yml keeps that list in one place and labels it the blast
radius.

What it adds

Dockerfile, docker-compose.yml, an entrypoint, a HEALTHCHECK, and
docs/docker.md. The build is multi-stage and runs the ROOT pnpm build, which
also syncs the dashboard the gateway serves — filtering to the jinn package yields
a gateway with no UI. pnpm deploy then ships a runtime with no build toolchain
in it: 215MB against the 650MB of workspace tree it replaces. claude-code is
pinned 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

  • seedTrust now records the Bypass Permissions consent that global onboarding
    implied up to claude-code 2.1.170 and no longer does. Without it every turn hangs
    on a dialog no PTY can answer.
  • Transcript reads, the Claude auth guard and the engine child env follow
    CLAUDE_CONFIG_DIR; five call sites hardcoded ~/.claude.
  • Stale pids are keyed on hostname plus boot identity. A container restarts pids
    from 1, so the reaper was SIGTERMing unrelated live processes — possibly PID 1.

The invariant worth reviewing

JINN_HOST/JINN_PORT describe this process's binding, and config.yaml sits on
a volume that outlives the container. loadConfig() resolves them into every
config it returns, so the hazard is a writer persisting them: a home later opened
on a workstation would silently bind every interface. saveConfigAtomic restores
the 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 docker CI job builds the image, then loads better-sqlite3 and spawns a PTY —
neither of which docker build exercises. It boots the gateway on an empty volume
and asserts /api/status, the dashboard actually served at /, HEALTHCHECK
reaching healthy, and recovery from a stale gateway.pid.

Yoobi 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.
@yoobi

yoobi commented Aug 4, 2026

Copy link
Copy Markdown
Author

@hristo2612 whenever you have the time. My proposition to support docker :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant