Real (non-headless) Google Chrome in Docker, for browser automation that should not self-identify as headless. chikin runs as a fleet: one gateway container fronts N per-name Chrome containers, each with its own sticky profile, and speaks MCP over HTTP. Several Claude Code instances (or any MCP client) each drive their own isolated browser through a single bearer-protected endpoint. Everything runs on one host, in containers — never remote.
- Full headed Chrome with no physical display (Xvfb). No
HeadlessChromein the User-Agent;navigator.webdriverisundefined;navigator.pluginspopulated. - Per-name sticky profiles: connect as
aliceand you always get the same cookies/history;bobis fully isolated. - On-demand lifecycle: a browser is provisioned on a session's first browser tool call — not on connect — and reaped when idle. Merely having chikin in your MCP config costs nothing, so idle clients can't sit on the fleet.
- noVNC for every browser, so a human can watch or solve a captcha from one dashboard.
- A per-browser host directory (
/tmp/chikin-shared/<name>) wired into that browser as~/Downloadsfor file upload/download.
- Deep stealth.
window.chrome.runtimeis missing, WebGL vendor/renderer report SwiftShader or null, andnavigator.pluginsis Chrome's default. That's a client-side concern — usepuppeteer-extra-plugin-stealthor similar. - Defense against sophisticated fingerprinters (mouse entropy, TLS fingerprinting).
Claude Code A (host) ─┐ ┌─ chikin-chrome-alice (CDP :9222, noVNC :6080,
Claude Code B (host) ─┤ HTTP/MCP + bearer │ vol chikin-profile-alice)
Claude Code C (host) ─┼──► GATEWAY ─────────────┼─ chikin-chrome-bob (…chikin-profile-bob…)
│ 127.0.0.1:8080 │
└ └─ (provisioned on first browser tool call, reaped when idle)
│ scoped docker API (tecnativa/docker-socket-proxy)
└─ create / start / stop chrome containers
- The gateway exposes one MCP endpoint per browser at
/b/<name>/.<name>must match[a-z0-9-]+(1–32 chars). It maps to containerchikin-chrome-<name>and volumechikin-profile-<name>. - On connect, the gateway spawns one
chrome-devtools-mcpchild and bridges the client's HTTP MCP session to the child's stdio. No container yet: the child answersinitialize,tools/listand pings on its own. On the session's first browser tool call the gateway provisions the container (creating the profile volume if needed), waits for Chrome to come up, and rebinds the child to that browser's CDP endpoint. - Networks. Three, since the control plane was split off (ADR 0002):
chikin-controlisinternal: trueand carries only gateway ↔ socket-proxy, so a compromised browser cannot reach the Docker API.chikin-netisinternal: trueand carries the gateway ↔ browser data plane (CDP, VNC).chikin-egressis a normal bridge giving browsers internet access, with inter-container forwarding off. No Chrome ports are published to the host. Browsers still sharechikin-netwith each other — cross-browser isolation is #20. - The gateway talks to Docker only through
tecnativa/docker-socket-proxy, scoped to containers + volumes + images (+ POST). Noexec, no hostinfo, no swarm/secrets.
Just want it running?
git clone https://github.com/jra3/chikin.git && cd chikin && ./install.sh— the guided installer checks preconditions, pulls the images, wires up the MCP server, and sets up login autostart. See docs/INSTALL.md for the full walkthrough (macOS + Linux, updates, uninstall). The steps below are the manual path.
Prerequisites: Docker 20.10+ with Compose v2, and ~1.5 GB disk for the images (the first pull takes a few minutes).
# 1. Pull the pinned gateway + fleet browser images from ghcr (builds nothing).
cp .env.example .env
# Already pinned: CHIKIN_VERSION defaults to an immutable per-commit `sha-<short>`
# tag, so this install is reproducible. Set another one to move (see .env.example).
docker compose --profile build pull
# 2. Bring up the gateway + socket-proxy.
# The gateway binds 127.0.0.1 only, so a bearer is optional. Leave GATEWAY_TOKEN
# empty for no-auth local use, or set one to require it:
# sed -i "s/^GATEWAY_TOKEN=.*/GATEWAY_TOKEN=$(openssl rand -hex 32)/" .env
docker compose up -d
# 3. Sanity check.
curl -s http://localhost:8080/healthz # {"status":"ok","config":{…},"warnings":[…],"canary":{…}}
open http://localhost:8080/ # fleet dashboard/healthz carries the effective runtime config of the running gateway — see
Checking the effective config — plus a
"canary" block ({"navStrikes":N,"childRespawns":N,"chromeVersions":[…]})
rolling the wedge watchdog up across the fleet; see
Wedge self-healing for how to read it.
The gateway listens on 127.0.0.1:8080 only. Browsers are not compose services — they appear on demand, on a client's first browser tool call.
Install once, and every Claude Code instance automatically gets its own multiplexed browser — no per-window config. A small stdio↔HTTP bridge (bin/chikin-mcp) derives a unique name per instance (inst-<pid>, overridable) and registers as one user-scope MCP server:
# bridge deps + put the helpers on your PATH
( cd client && npm install --omit=dev )
ln -s "$PWD/bin/chikin-mcp" ~/.local/bin/chikin-mcp
ln -s "$PWD/bin/chikin-claude" ~/.local/bin/chikin-claude
# register the gateway once, for all projects
claude mcp add --scope user chikin -- ~/.local/bin/chikin-mcpNow any claude instance — even several in the same directory — gets its own isolated browser, and the gateway multiplexes them (up to MAX_FLEET). The default name inst-<pid> is unique per running instance; pin a sticky, persistent browser by name with the wrapper (it just exports CHIKIN_NAME):
chikin-claude giard # this instance drives the sticky "giard" profile
chikin-claude carey --continue # another instance, isolated "carey"Env (read by chikin-mcp): CHIKIN_GATEWAY (default http://localhost:8080), CHIKIN_NAME (explicit browser name), CHIKIN_TOKEN (bearer, only if GATEWAY_TOKEN is set).
The first browser tool call provisions and starts the browser (about a second). Connecting alone does not — a client that never browses holds no fleet slot. A named browser (giard) always gets the same profile. Disconnect and the browser stays warm for a fast reconnect; leave it idle past IDLE_TTL_SEC with no client attached and it's removed — for a named browser the profile volume is preserved, so reconnecting restores everything, while a disposable inst-<pid> browser's profile goes with it (see Profile volumes).
A connected client that never browses costs nothing at all — it holds no container, so it can't sit on a slot however long it stays open. And a session that did browse but has run no browser tool for ATTACHED_IDLE_TTL_SEC (4h) is reclaimed even while attached; the client bridge reconnects transparently on the next tool call.
The stdio bridge above is the easy path — it auto-assigns each Claude Code instance its own browser. If instead you want to pin a client to a specific named browser, or wire up any MCP client that speaks streamable HTTP directly, point it at the gateway's per-browser endpoint http://localhost:8080/b/<name>/ (<name> is [a-z0-9-]+, 1–32 chars). To configure two isolated browsers:
# Two MCP servers, two isolated profiles. Drop --header if GATEWAY_TOKEN is empty.
claude mcp add --transport http alice http://localhost:8080/b/alice/ \
--header "Authorization: Bearer $GATEWAY_TOKEN"
claude mcp add --transport http bob http://localhost:8080/b/bob/ \
--header "Authorization: Bearer $GATEWAY_TOKEN"alice and bob get fully isolated profiles (volumes chikin-profile-alice, chikin-profile-bob); the gateway provisions each browser on its first browser tool call. Only one client may hold a given name at a time — a second concurrent connect to alice is rejected with 409. This is exactly the form any streamable-HTTP MCP client uses; chikin-mcp is just a convenience wrapper that fills in the name (and the bearer) for you.
Breaking change to the client contract. Every session must now identify itself before it can use any browser tool.
A session's browser name (inst-<pid>) says which profile it drives, not what the driving instance is doing. So the gateway injects a synthetic chikin_identify tool, and blocks every browser tool until the session calls it:
- Required first. Any browser tool (
navigate_page,new_page, …) called before identifying returns an instructive error namingchikin_identify, the handle format, and an example.initialize,tools/list,chikin_identify, andchikin_resetare never blocked. handleis required, unique across live sessions. A handle already held by another live session is rejected with a clear error — pick another. It's a display/correlation label only; the sticky profile stays keyed by the browser name (identifying is orthogonal to the profile, and must be re-done on each reconnect).- Surfaces everywhere: a handle column in the dashboard, the session's log lines, and the noVNC page title.
Self-directing — no docs required. A caller with zero prior knowledge of chikin is steered to correct usage by the MCP itself: the initialize result's instructions state the contract up front, chikin_identify's own tool description is fully self-explanatory (format, uniqueness rule, worked example), and the gating error on any premature browser tool is actionable. A Claude-driven client therefore adapts automatically — no client changes needed beyond letting it read the MCP's own context.
Open the dashboard at http://localhost:8080/ and click open noVNC next to any running browser, or go straight to http://localhost:8080/vnc/<name>/. You can drive that Chrome window by hand — useful for logging in or clearing a captcha while the MCP client keeps the session. The page title and the dashboard's handle column show which session (chikin_identify handle) owns each browser.
The dashboard is also the only place the connected-vs-driven split is visible: fleet slots in use: N/MAX counts browsers that exist — every fleet container, i.e. every name that has made a browser tool call and not yet been reaped, including ones whose client has since disconnected. Live sessions that have never made one hold no slot and are listed as connected — holds no fleet slot, with no noVNC link (there is no browser to view yet — that URL 502s until one exists). The browser idle column is time since a real browser tool call, which is what the attached reap TTL measures; the plain idle column stays near zero on any attached session because the client bridge pings. The strikes, respawns and chrome columns are the wedge canary — read them together, as Wedge self-healing explains.
chikin-record captures a running browser to an mp4 and/or animated GIF in one command. It records the browser over CDP Page.startScreencast (reached at the container's IP on chikin's docker network — the fleet never publishes port 9222 to the host) and assembles the frames with ffmpeg.
# 8s mp4 of the current page in browser "giard"
chikin-record giard
# navigate first, produce both an mp4 and a GIF, into ./clips
chikin-record giard --mp4 --gif --url https://example.com --seconds 10 --out ./clips
chikin-record --help # full options: --seconds --fps --width --out …Outputs are named <name>-<timestamp>.mp4 / .gif. If neither --mp4 nor --gif is given it defaults to an mp4.
Prerequisites: ffmpeg and Node ≥ 22 on the host (the global WebSocket used to drive CDP needs Node ≥ 22), plus a running browser — connecting alone no longer provisions one, so drive one browser tool call against /b/<name>/ first (e.g. chikin-claude <name>, then have it navigate somewhere) to provision chikin-chrome-<name> before recording.
Timing note: screencast frames are event-driven — Chrome emits one only when the page changes visually, not at a fixed fps — so chikin-record timestamps every frame and reconstructs real timing (a mostly-static page still yields a full-length clip by holding the last frame). A page with no visual change at all can emit very few frames.
Remote-host caveat: this uses the direct-CDP-via-container-IP path, which needs the host to be able to route to chikin's docker bridge network (true when the fleet runs on this machine). On a remote or locked-down host where the container IP isn't reachable, a future fallback could screen-record the noVNC/Xvfb display with ffmpeg's x11grab; that fallback is not built yet.
Each browser starts with a fresh profile, so you'd normally have to log into sites every time. Instead, seed every new browser from a golden profile you log into once:
# 1. enable seeding (gateway env) and RECREATE the gateway. Run compose from the
# repo dir so it reads this .env — container env is fixed at create time, so
# `docker restart` can never pick up an .env change.
echo 'SEED_VOLUME=chikin-seed' >> .env && docker compose up -d --force-recreate gateway
# then confirm the RUNNING gateway actually got it:
curl -s localhost:8080/healthz | grep -o '"seedVolume":"[^"]*"' # -> "seedVolume":"chikin-seed"
# 2. create the golden browser, then log into your sites by hand, once
chikin-claude golden # attach a client to the name "golden"
# -> ask it to browse once ("open example.com"). That FIRST browser tool call is
# what creates chikin-chrome-golden and chikin-profile-golden — attaching
# alone provisions nothing, so /vnc/golden/ 502s until you make it.
# -> now open http://localhost:8080/vnc/golden/ and sign in to your sites
# 3. freeze it as the seed
chikin-snapshot # clones golden's profile -> chikin-seedNo MCP client handy? client/keepalive.mjs does step 2 for you from a repo checkout — it identifies, makes one browser tool call to provision the browser, and then keeps driving it so it isn't reaped while you log in:
# prefix GATEWAY_TOKEN=<token> (or CHIKIN_TOKEN) if you set a bearer in .env
node client/keepalive.mjs http://localhost:8080/b/golden/ # ^C when you're donechikin-profile-golden and chikin-seed are now the most expensive things on the host to lose, and a single docker volume prune --all takes both — read Profile volumes and cleaning them up before you run any prune.
From then on every new browser is cloned from the seed and starts logged in — and the MCP automation sees those cookies (it shares the persistent profile context). Re-run chikin-snapshot whenever sessions expire. It works because every container uses Chrome's keyring-less basic cookie store, so the encryption key travels in the copied Local State and decrypts in the clones. Caveat: all seeded browsers share one identity, so sites that forbid concurrent sessions may re-challenge.
Each browser <name> gets its own host directory /tmp/chikin-shared/<name>, mounted only into that browser as ~/Downloads (and at the same /tmp/chikin-shared/<name> path, which is what upload_file expects). Drop upload files under the per-name dir; downloads triggered in that browser land back there. Scratch files are not shared across clients — each browser sees only its own subdir (M2 / CHK-007); cookies/profile are per-name isolated too.
Caution
No docker volume prune --all is safe against chikin volumes — filtered or not.
Both forms destroy hand-authenticated login state, and the only recovery is signing
back into every site by hand through noVNC. They differ only in what they take:
docker volume prune --all --filter label=chikin.fleet=1 # ☠️ golden + hermes + every sticky profile
docker volume prune --all # ☠️☠️ the above, AND chikin-seed- The label-scoped form looks chikin-aware and careful, and is the more likely
mistake.
chikin-profile-goldencarrieschikin.fleet=1exactly like the throwaway per-instance profiles, and it sits dangling whenever no container has it mounted — which is almost always. So do every sticky client profile. - The unfiltered form is worse.
bin/chikin-snapshotcreates the seed with a plaindocker volume create, sochikin-seedcarries no labels at all: no label filter can reach it, but a bare--alltakes it along with everything else. That is the volume new browsers are actually cloned from whenSEED_VOLUMEis set, and in practice it is golden's only second copy of the login state. Losing both is terminal in a way losing either alone is not:chikin-snapshotrefuses to run withoutchikin-profile-golden, so once golden is gone the seed can never be refreshed again without re-authenticating every site by hand.
The trap is armed by --all: the plain prune skips named volumes and reports
Total reclaimed space: 0B, which reads as "nothing to clean here" and pushes you
straight to --all. Delete instance profiles by name instead:
docker volume ls -q --filter name=chikin-profile-inst- # look first
docker volume ls -q --filter name=chikin-profile-inst- | xargs -r docker volume rm(docker volume rm refuses volumes a container still mounts, so this is safe to
run against a live fleet.) Keep golden and the seed as genuinely separate copies:
bin/chikin-snapshot refreshes chikin-seed from golden, and a tarball of either,
outside Docker's volume store, is what survives a prune of both.
Two kinds of profile volume, told apart by name:
| Volume | Lifetime |
|---|---|
chikin-profile-inst-<pid> |
Disposable. One per Claude Code instance (the default inst-<pid> name). The gateway removes it when it reaps the browser. |
chikin-profile-<name> — every name that is not inst-* |
Sticky. Survives reaping, restart, and --force-recreate. Never removed by the gateway. golden and hermes are just the well-known ones; any name you or a client picks (alice, mulm, …) is in this class, and yours is not safe merely because it is not listed here. |
chikin-seed |
Sticky, and unlabelled. The snapshot new browsers are cloned from (SEED_VOLUME). Not a chikin-profile-* volume, so the by-name commands above never touch it — but a bare docker volume prune --all does. |
Volumes created from this version on also carry chikin.role=instance vs
chikin.role=profile, so --filter label=chikin.role=instance is a prune scope
that cannot reach golden, any other sticky profile, or the seed:
docker volume prune --all --filter label=chikin.role=instanceBut Docker volume labels are immutable after creation, so any volume that
predates this change — including the chikin-profile-golden already on your host —
has no chikin.role label at all and is not protected by it. On an existing host,
use the name-based commands above. The gateway's own safety checks always go by
name, never by label, for exactly this reason. And no label filter of any kind
saves you from an unfiltered docker volume prune --all.
The gateway also sweeps orphaned chikin-profile-inst-* volumes (instance profiles
whose container no longer exists) once at startup — that reclaims leftovers from
before it removed them with the container. Set CHIKIN_VOLUME_GC=0 to disable.
The reaper does the same during normal operation for a name it is tracking whose
container went away out-of-band, once that name has no session, no open stream,
and has been idle past IDLE_TTL_SEC — sticky profiles are never candidates.
Set in .env (see .env.example) or the environment.
| Variable | Default | Meaning |
|---|---|---|
GATEWAY_TOKEN |
(empty) | Bearer token clients must present. Empty disables auth — safe because the port is bound to 127.0.0.1. Set one (openssl rand -hex 32) to require it. |
CHIKIN_SANDBOX |
auto |
Chrome renderer-sandbox policy (H1). auto sandboxes where the host permits unprivileged user namespaces and falls back to --no-sandbox (loud WARN) where it doesn't; on forces it (fails loudly if unsupported); off forces --no-sandbox. See Renderer sandbox. |
MAX_FLEET |
8 |
Max concurrent browsers — counting only those actually provisioned, i.e. sessions that have made a browser tool call. A browser tool call past the cap comes back as a retryable tool error naming the cap; the session stays up with every tool registered, so it just works once a slot frees. |
BROWSER_MEMORY_MB |
3072 |
Hard RAM cap per browser (swap pinned equal — no swap escape). Must exceed the 2g /dev/shm each browser gets (that tmpfs is charged to the same cgroup); 3072 leaves ~1g headroom for Chrome above a full shm. 0 disables. |
BROWSER_PIDS_LIMIT |
512 |
Max processes/threads per browser — the fork-bomb guard. 0 disables. |
BROWSER_CPUS |
2.0 |
CPU cap per browser in cores (fractions allowed, e.g. 1.5); mapped to Docker NanoCpus. 0 disables. |
BROWSER_NOFILE |
8192 |
Open-file-descriptor ceiling per browser (soft=hard). Kept generous because Chrome is fd-hungry. 0 disables. |
SEED_VOLUME |
(empty) | Docker volume cloned into every new profile so browsers start logged in. Empty = off. Populate with bin/chikin-snapshot (see Pre-authenticated browsers). |
IDLE_TTL_SEC |
900 |
Idle seconds before a detached browser (no attached client stream) is reaped. Measured against any MCP traffic. |
ATTACHED_IDLE_TTL_SEC |
14400 |
Seconds an attached browser may go with no real browser tool call before it is reclaimed anyway. Measured against actual forwarded tools/calls — not the client bridge's keepalive ping, which by design keeps the plain idle clock fresh — and shown as the dashboard's browser idle column. Without this, a window that made one browser tool call and then went idle holds that fleet slot for its whole lifetime and the fleet saturates with browsers parked on about:blank. Eviction is survivable: the bridge reconnects transparently, though a disposable inst-* browser's profile is discarded with it (logged explicitly). 0 = never reap an attached browser (pre-#57 behaviour). Keep it well above IDLE_TTL_SEC. |
REAP_INTERVAL_SEC |
30 |
How often the reaper sweeps. |
CHIKIN_VOLUME_GC |
1 |
Sweep orphaned chikin-profile-inst-* volumes (disposable profiles whose container is gone) once at startup. Scoped by name — golden, hermes and named client profiles are never candidates. 0 disables. See Profile volumes. |
PROVISION_TIMEOUT_SEC |
90 |
How long to wait for a new browser's CDP to come up. Nothing is provisioned when a client connects, so this bounds the first browser tool call — overrunning it fails that one call as a retryable tool error ("chikin could not start a browser"), leaving the session up with every tool registered. |
WINDOW_SIZE |
1920,1080 |
Chrome window / Xvfb screen size for provisioned browsers. |
CDM_EXTRA_ARGS |
(empty) | Extra flags for every chrome-devtools-mcp child, whitespace-separated. E.g. --experimentalPageIdRouting lets page-scoped tools be routed by an explicit pageId. It is not wedge protection: upstream routes by id only when the caller passes a pageId, and falls back to the same sticky selected page otherwise — and the gateway cannot supply that id on the caller's behalf, because the page a call was meant for is the selected page, the very signal that goes stale in a wedge. Without a caller-side contract to pass ids it changes nothing; it also changes tool schemas, which an MCP client fixes at session start. Off by default. |
NAV_VERIFY_DELAY_MS |
2500 |
Settle time the wedge watchdog waits after a "successful" navigation before re-sampling the browser's real CDP page list (it also samples it at reply time). A client navigating faster than this has a nav judged only when it names the same page as the newest one (the wedge signature); the gateway warns when verifications keep being skipped that way. See Wedge self-healing. |
LOG_LEVEL |
info |
debug | info | warn | error. |
.env on disk is not what the gateway is running. Container env is frozen at
docker create time, so a gateway created from a directory where compose never
read your .env runs with entirely different values — and no docker restart
fixes it, only a recreate. (This is exactly how profile seeding stayed silently
off for ~7 weeks while every check of the config on disk said "configured".)
So the gateway reports what it has, in three places — no docker exec needed:
curl -s localhost:8080/healthz | python3 -m json.tool # config + warnings
open http://localhost:8080/ # "runtime config" panel
docker logs chikin-gateway | grep '^\[warn\] config:' # startup bannerStartup states seeding either way, e.g. seeding: ON (volume=chikin-seed) or a
WARN seeding: OFF (SEED_VOLUME unset — new browsers get blank profiles …).
If a seed volume exists on the host while SEED_VOLUME is unset, that's flagged
as an explicit warning on all three surfaces. GATEWAY_TOKEN is reported only as
a boolean (authEnabled) — these surfaces are unauthenticated.
To fix a drifted gateway, recreate it from the repo dir so compose reads .env:
docker compose up -d --force-recreate gateway.
chrome-devtools-mcp (≤1.1.1) can bind to a stale page target after an SPA route change or cross-origin navigation: navigation tools then return success but silently no-op while Chrome itself is healthy. The gateway defends in three layers:
- Nav watchdog — after the child reports a navigation succeeded, the gateway compares the page the child reports it is bound to against the container's CDP
/json/list(ground truth), sampled both at reply time and after a settle delay. Two consecutive navs on which the child is provably bound to a page the browser does not have force a transparent child respawn, which re-binds the browser's real current target. The test is deliberately not "did the page set move?" — a nav that redirects onto the page you are already on moves nothing while the child is perfectly healthy, and a page that redirects on its own after the reply is still cleared by the reply-time sample. Repeated CDP connection failures on the child's stderr (e.g. the container was removed out-of-band) trigger the same respawn. chikin_resettool — injected into everytools/list(alongsidechikin_identify, see Identify your session first), so the model itself can hard-reset a wedged browser (container recreated, profile/logins preserved) without human help.- Self-healing transports — both the client bridge and the gateway replay the cached
initializeover a rebuilt link, so none of the above ever drops the client's MCP session.
Reading the canary. The watchdog is not a fix for the wedge — it is a canary on an unpinned dependency, so it reports two different numbers and you want the gap between them:
strikes(canary.navStrikes) are suspicions: a nav verification that disagreed with the browser. This is the cumulative per-browser count, deliberately not the consecutive counter the bridge escalates on.respawns(canary.childRespawns) are actions: a child actually torn down and replaced, for any cause — wedge verdict, transport close, CDP-failure streak,chikin_reset.
Many strikes with no respawns is the informative state: the detector is firing on something systematic that is not a wedge (both false-positive classes fixed in #72 had exactly that shape). A single combined number would hide it, which is why there are two.
The chrome column (canary.chromeVersions, a set — more than one entry means an image rotated under long-lived containers) is read from the running browser's CDP /json/version at every attach and child swap, and stamped into the strike/respawn log lines. It is load-bearing, not trivia: Dockerfile leaves google-chrome-stable deliberately unpinned (the accepted CHK-009/M4 non-reproducibility residual), and Chrome — not chrome-devtools-mcp, which has been the same 1.1.1 throughout — is the variable that governs whether the wedge reproduces at all (147.0.7727.101 wedged readily; 150.0.7871.181 does not reproduce). A strike that cannot name its Chrome is as unattributable as the original report was. If the browser cannot be probed the value is cleared rather than carried over, so — / chrome unknown means exactly that.
These counters are gauges, not monotonic totals. They live on the browser's activity record, which the reaper deletes when it reclaims a name — so the /healthz totals drop when a browser is reaped, and a reaped browser's strike history leaves the dashboard entirely. The log lines are the durable record; anything scraping /healthz must not treat these as ever-increasing counters.
SSE keepalive. Node's fetch (undici) kills a response body that has been idle for 300s, and the MCP event stream is silent whenever a client sits between tool calls — so every long-lived session used to be torn down and rebuilt on a ~301s cycle. That was invisible except for one symptom: a reconnect frees the session's chikin_identify handle, so sessions kept losing their identity (and, with it, access to every browser tool until they re-identified) every five minutes. The gateway now writes a : keepalive SSE comment into open streams every 30s. It is protocol-invisible, and it covers POST replies too, so a tool call slower than 300s no longer dies mid-flight.
Gateway responses use JSON-RPC error envelopes with these HTTP statuses: 401 (bad/missing token), 400 (invalid name or non-initialize without a session), 409 (a name already has an active session), 503 (session setup failed). A full fleet is no longer an HTTP status: since provisioning is lazy it surfaces on the offending tool call, as a retryable tool error.
Environment variables read by entrypoint.sh (the fleet sets these when it provisions each browser):
| Variable | Default | Meaning |
|---|---|---|
CDP_PORT |
9222 |
Port inside the container the gateway reaches via socat. |
WINDOW_SIZE |
1920,1080 |
--window-size for Chrome; also drives the Xvfb screen dimensions. |
DISPLAY_NUM |
99 |
Which :N display Xvfb creates. |
ENABLE_VNC |
0 |
1 to start x11vnc + noVNC on VNC_PORT. The fleet sets this automatically. |
VNC_PORT |
6080 |
noVNC/websockify port inside the container. |
CHIKIN_SANDBOX |
auto |
Renderer-sandbox policy auto|on|off (H1). The fleet passes this down from the gateway; the entrypoint probes the host's unprivileged-userns support and drops (or keeps) --no-sandbox accordingly. See Renderer sandbox. |
EXTRA_CHROME_ARGS |
(empty) | Appended to Chrome's argv. |
Chrome (since ~v111) ignores --remote-debugging-address=0.0.0.0 and always binds CDP to 127.0.0.1 inside the container. entrypoint.sh runs Chrome on a private loopback port (9223) and uses socat to forward CDP_PORT to it — which is how the gateway (over the internal network, never the host) reaches each browser's CDP.
The image is multi-arch:
- linux/amd64 — Google Chrome stable.
- linux/arm64 — Debian's Chromium (Google ships no
google-chrome-stablefor Linux arm64). The User-Agent saysChromium/<ver>, and Google's proprietary codecs (H.264, AAC) are absent. The anti-detection signals chikin targets are upstream Blink and behave identically.
On Apple Silicon / Linux ARM, docker compose pulls the arm64 image natively.
The container starts as root only long enough for entrypoint.sh to chown the /data profile volume (the fleet creates fresh, root-owned volumes), then drops to the unprivileged chrome user (UID 1100) via setpriv before launching anything.
- CDP has no authentication. chikin never publishes a Chrome port to the host. In fleet mode the only host-exposed surface is the gateway on
127.0.0.1:8080, and/b/<name>/requires a bearer token; the control-plane network isinternal: true. - Linux caveat: on a Linux host you can still reach a container's CDP by its container IP (e.g.
http://172.x.x.x:9222) because the host routes to Docker bridges directly —internal: truedoes not change this. The boundary chikin provides is "not reachable from other machines and not on any host port." If you need to block host-local access too, add aDOCKER-USERiptables rule; that's outside chikin's scope. - Scoped Docker access. The gateway reaches Docker only through
tecnativa/docker-socket-proxywith a read-only socket mount, scoped to containers/volumes/images (+POST).exec,info, swarm, and secrets are denied — verify with the proxy returning403on/infoand/exec/.... - Chrome's renderer sandbox is ON by default where the host supports it (see Renderer sandbox below). This closes the H1 audit finding: a renderer exploit from a hostile page no longer means immediate in-container code execution — it now also needs a sandbox escape. On a host that can't sandbox, chikin falls back to
--no-sandbox(loud WARN); there, still treat a profile volume as a fully compromised browser profile after visiting untrusted content. - Never change the gateway's host port binding from
127.0.0.1. - Audit history. chikin was audited on 2026-07-21 (15 findings); all 15 are resolved. The tracking issue #34 is the record — it lists each finding with the PR that closed it, plus the one deliberately accepted residual (peer browsers can still reach each other's CDP
:9222and noVNC:6080, per ADR 0003, asserted asEXPECTED_PEER_REACHABLEinitest/gateway-reachability.mjs). There is no separate audit report file, by design: a second copy with nothing keeping it current is how a stale document ends up read as current posture.
Chrome runs each renderer in its user-namespace sandbox, so a renderer RCE from a malicious page is contained instead of being immediate code execution as the container's chrome user (uid 1100). This holds together with chikin's existing least-privilege posture — no capability is added and no-new-privileges stays on; the only change is a custom seccomp profile (Docker's own default plus one allow group for the five syscalls Chrome's sandbox needs: clone, clone3, unshare, setns, chroot) so the sandbox can build its namespaces under CapDrop: ["ALL"]. Every other default-deny rule (mount, bpf, ptrace, kexec, keyring, perf_event_open, …) stays intact.
Host requirement: the sandbox needs the host to permit unprivileged user namespaces (most modern Linux; e.g. sysctl kernel.unprivileged_userns_clone=1, and on Ubuntu ≥23.10 kernel.apparmor_restrict_unprivileged_userns=0 or a permissive profile). Where that isn't available Chrome hard-fails to boot rather than silently downgrading, so chikin detects the prerequisite and acts on the CHIKIN_SANDBOX knob:
CHIKIN_SANDBOX |
Behavior |
|---|---|
auto (default) |
Run sandboxed when the host permits unprivileged user namespaces; otherwise fall back to --no-sandbox so the browser still boots, logging a loud WARN that the sandbox is disabled and why. |
on |
Force sandboxed. If the host can't support it, the browser fails loudly to boot rather than silently degrading. |
off |
Force --no-sandbox (the pre-hardening behavior). |
Per-browser posture is shown on the dashboard (a sandbox column: sandboxed / fell back / disabled) and in each container's log (CHIKIN_SANDBOX_STATUS=…). Confirm a specific browser's real state authoritatively with:
node bin/chikin-sandbox-check <name> # reads chrome://sandbox over CDP → "adequately sandboxed"Do not trust /proc/<pid>/status Seccomp: for this — it reads 2 even for the unsandboxed --no-sandbox baseline (that's the container-wide Docker seccomp, not Chrome's sandbox). chrome://sandbox is the ground truth.
verify/verify-fleet.js proves a fleet browser is non-headless — it drives a browser through the gateway (the fleet never exposes CDP to the host) and runs the probe. Easiest via make verify:
make verify # provisions a browser, checks it
# or directly:
cd verify && npm install
node verify-fleet.js # against http://localhost:8080
node verify-fleet.js --json # machine-readable
node verify-fleet.js --sannysoft # also scrape bot.sannysoft.com
node verify-fleet.js --expect-sandbox # also REQUIRE chrome://sandbox == "adequately sandboxed" (H1)--expect-sandbox (or CHIKIN_EXPECT_SANDBOX=1) makes the run assert Chrome's renderer sandbox is real — use it on a userns-capable host (CI does, so a silent drop back to --no-sandbox is caught). Without it the sandbox status is still reported, just informationally. For a one-off check of a single browser, node bin/chikin-sandbox-check <name>.
Exit codes: 0 all required checks passed · 1 a required check failed · 2 couldn't connect to the gateway · 3 unexpected error.
cd gateway
npm install
npm run build # tsc -> dist/
npm test # unit tests (run in CI too)Needs Node ≥ 22 on the host (the gateway's engines, matching its
node:22-bookworm-slim runtime): npm test runs node --test "dist/test/*.test.js",
and --test only expands a glob from Node 21 on — on Node 20 the pattern is taken
literally and nothing runs.
Local images need the dev override — always. docker-compose.yml hardcodes
CHROME_IMAGE: ghcr.io/jra3/chikin:${CHIKIN_VERSION} (only the tag is variable,
so a CHROME_IMAGE entry in .env is inert). Bringing a dev checkout up with the
base file alone therefore points the gateway at a ghcr image it may not have, and
it crash-loops on its startup image check. Use the override — one command:
make dev-build && make dev-up # = docker compose -f docker-compose.yml -f docker-compose.dev.yml …bin/chikin-preflight runs before both make up and make dev-up and fails with
the exact command to use if the selected images aren't runnable; bin/chikin-up
(the autostart entry point) picks the file set for the checkout automatically.
Independently, the gateway now pulls a missing registry image at startup rather
than dying, so the plain docker compose up -d path self-heals where it can.
The gateway is TypeScript on the official MCP SDK (StreamableHTTPServerTransport facing clients, StdioClientTransport to each chrome-devtools-mcp child) with dockerode for provisioning and http-proxy for the noVNC reverse proxy. See gateway/src/ — server.ts (routing/auth), provisioner.ts (Docker lifecycle), bridge.ts (MCP↔stdio pump), reaper.ts (idle reclaim).
Gateway healthy but curl localhost:8080 refuses from the host. Compose sets HOST=0.0.0.0, which the gateway reads as "listen broadly, except on the browser data plane": it resolves its own chikin-egress address at startup and binds loopback plus that address, never chikin-net (CHK-002 — see ADR 0003). Docker's port-forward reaches it over the egress address; loopback-only safety comes from the 127.0.0.1:8080:8080 host mapping. If that resolution ever fails the gateway falls back to all interfaces and says so in /healthz's warnings — check there first. A HOST set to anything other than 0.0.0.0 is bound verbatim, so pinning it to 127.0.0.1 will refuse host traffic exactly this way.
The first browser tool call hangs, then returns "chikin could not start a browser". Chrome didn't come up within PROVISION_TIMEOUT_SEC (connecting provisions nothing, so this can only surface on a tool call, never on the connect). If the message also says no slot is missing, check docker logs chikin-chrome-<name> and the gateway's own log for the underlying Docker error; most often it's /dev/shm pressure (the fleet sets shm_size 2 GB per browser). The session survives — the same call works once the browser can be built.
"browser '' already has an active session" (409). That name is in use by another client. Pick a different name, or have the other client disconnect (MCP DELETE/terminate frees the name immediately).
"fleet is full" on a browser tool call. Every slot is held by a browser that has actually been driven. Raise MAX_FLEET, let an idle browser get reaped, or retry — the session survives, so the same call succeeds once a slot frees. The dashboard's "fleet slots in use" line and browser idle column show what is holding them.
New browsers are logged out / the golden profile isn't applied. Seeding is off
in the running gateway. Check curl -s localhost:8080/healthz | grep seed or the
dashboard's runtime config panel — not .env. Fix with
docker compose up -d --force-recreate gateway from the repo dir (see
Checking the effective config).
Gateway restarts in a loop right after docker compose up -d. It couldn't get
its CHROME_IMAGE (docker logs chikin-gateway names the fix). In a dev checkout
use make dev-up; on the pinned path make pull up.
make verify says UA contains HeadlessChrome. A --headless flag snuck into entrypoint.sh.
MIT. See LICENSE.