8000 GitHub - jra3/chikin: Real (non-headless) Google Chrome in a Docker container, exposing CDP on port 9322 · GitHub
[go: up one dir, main page]

Skip to content
jra3Public

About

Real (non-headless) Google Chrome in a Docker container, exposing CDP on port 9322

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

chikin

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.

What you get

  • Full headed Chrome with no physical display (Xvfb). No HeadlessChrome in the User-Agent; navigator.webdriver is undefined; navigator.plugins populated.
  • Per-name sticky profiles: connect as alice and you always get the same cookies/history; bob is 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 ~/Downloads for file upload/download.

What you do NOT get

  • Deep stealth. window.chrome.runtime is missing, WebGL vendor/renderer report SwiftShader or null, and navigator.plugins is Chrome's default. That's a client-side concern — use puppeteer-extra-plugin-stealth or similar.
  • Defense against sophisticated fingerprinters (mouse entropy, TLS fingerprinting).

Architecture (fleet mode)

 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 container chikin-chrome-<name> and volume chikin-profile-<name>. A driver that speaks raw DevTools Protocol rather than MCP reaches the same browser at /cdp/<name>/ — see the CDP lane.
  • On connect, the gateway spawns one chrome-devtools-mcp child and bridges the client's HTTP MCP session to the child's stdio. No container yet: the child answers initialize, tools/list and 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-control is internal: true and carries only gateway ↔ socket-proxy, so a compromised browser cannot reach the Docker API. chikin-net is internal: true and carries the gateway ↔ browser data plane (CDP, VNC). chikin-egress is a normal bridge giving browsers internet access, with inter-container forwarding off. No Chrome ports are published to the host. Browsers still share chikin-net with each other — cross-browser isolation is #20.
  • The gateway talks to Docker only through tecnativa/docker-socket-proxy, scoped to containers + volumes + images (+ POST). No exec, no host info, no swarm/secrets.

Quickstart (fleet)

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.

Wire up a client

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-mcp

Now 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.

Direct HTTP transport (pin a browser, or non–Claude-Code clients)

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.

Identify your session first (chikin_identify) — required

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:

// first tool call on a fresh session
{ "name": "chikin_identify",
  "arguments": { "handle": "mulm-login-fix",           // required: unique slug, [a-z0-9-], 1–32 chars
                 "description": "debugging the OAuth callback" } }  // optional free text
  • Required first. Any browser tool (navigate_page, new_page, …) called before identifying returns an instructive error naming chikin_identify, the handle format, and an example. initialize, tools/list, chikin_identify, and chikin_reset are never blocked; chikin_snapshot, chikin_act and chikin_achieve are gated like browser tools.
  • handle is 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.

Read and act on the page as a table (chikin_snapshot, chikin_act)

The gateway injects two tools for fast, goal-driven use (issue #95). chikin_snapshot reads the page in one atomic step as a numbered table of every visible, enabled control (e1, e2, …), with its role, label and current value, plus the visible text. chikin_act carries out one row of that table: a click, typing into a field, a dropdown choice, a scroll, or a wait. It replies with the page as it is afterwards, as a new table. A model reads rows and picks one, instead of parsing an accessibility tree and naming uids.

{ "name": "chikin_snapshot", "arguments": {} }                                  // the page last read, else the one the last navigation selected
{ "name": "chikin_snapshot", "arguments": { "url": "https://example.org/x" } }  // a named open page (origin + path)
{ "name": "chikin_act", "arguments": { "id": "e3" } }                           // click / select / scroll_down / wait
{ "name": "chikin_act", "arguments": { "id": "e5", "text": "Zurich" } }         // fill: replaces the field's value
  • Ids belong to the most recent table only. Every read renumbers them, and they are not take_snapshot uids, so upstream click/fill cannot use them.
  • Nothing is input unless the row still means what it meant. Before acting, the gateway checks that the control and its surroundings (form, dialog, row) are unchanged since the read, and that nothing covers it. Otherwise nothing happens, and the error carries the page as it is now. Once input has gone out, the reply never asks for a retry, so a caller never clicks twice: an unreadable page afterwards is still reported as done, and an input left unanswered (usually a JavaScript dialog the click opened; handle_dialog dismisses it) is reported as sent. If the tab that was read is gone, nothing is input and the reply asks for a fresh chikin_snapshot.
  • Both go over the gateway's own CDP connection, never through chrome-devtools-mcp. They neither wait on nor hold the child's per-session tool mutex, so a take_screenshot stuck on a background tab does not stall them. ADR 0007 records why.
  • Gated like any browser tool. Identify first. The first call provisions the browser, and every call counts as browser activity.
  • Which page. A read without url stays on the tab the gateway last read, until the child navigates or changes its selection. When more than one open page fits, or none does, it refuses and lists what is open (a stale selection falls back to the only open page, if there is just one).
  • Limits. Shadow DOM and frames are not traversed, and password fields never appear. The in-page scripts are Browser Use's jev-ultrafast, verbatim.

Run a goal to the end (chikin_achieve)

With a decision model configured (CHIKIN_ANTHROPIC_API_KEY or CHIKIN_OPENROUTER_API_KEY, see Configuration), the gateway also offers chikin_achieve. The caller states a goal once. A fast loop inside the gateway then repeats three steps: read the page as a chikin_snapshot table, ask a fast model (Claude Haiku 5.5 by default, or TypeSafe's Jev on an OpenRouter key) which row moves the goal forward, and do that row with chikin_act's guards. It stops at DONE, at BLOCKED, or when a budget runs out. The caller gets back one result: the outcome, a step-by-step trace, and the page as it ended. That is one tool c F438 all where driving the page yourself costs a big-model round trip per click (issue #95).

{ "name": "chikin_achieve", "arguments": {
    "goal": "Search Wikipedia for \"Alan Turing\" and open his article.",
    "max_steps": 20,                              // decision budget, default 25, at most 60
    "max_seconds": 120,                           // wall-clock budget, default 120, at most 600
    "allowed_origins": ["https://en.wikipedia.org"],  // stop before acting anywhere else
    "allow_consequential": false } }              // default: stop before buy/send/delete/final submit
  • Navigate first. It starts on the page you are on, chosen the way chikin_snapshot chooses.
  • Put every value in the goal. Text to type comes from the goal; a value the goal does not give is never invented, and the loop stops BLOCKED instead.
  • Consequential steps wait for you. When the model marks the next row as committing something hard to undo, the loop stops with needs_confirmation and names the row. Do it yourself with chikin_act and that id, then call chikin_achieve again. Pass allow_consequential: true to let it through.
  • DONE is the model's claim, returned with the page it rests on. Check that page when it matters.
  • Unsure steps are escalated. With Jev deciding, a step Jev picks at an operation probability below 0.6 goes to CHIKIN_TEXT_MODEL instead; the trace marks it escalated. If that call fails, Jev's pick stands. In the live runs every wrong step was one of those.
  • Stops on its own after three actions in a row that leave the page unchanged, after two answers naming rows that are not there, after the same row is refused three times in a row for the same reason, when the model declines, when an input may not have completed (a JavaScript dialog), and when a row cannot be done because the tab is gone (nothing is input for that step). It never retries an input that landed.
  • Long calls behave. It sends one notifications/progress per step when the call carries a progress token, and a client cancel stops it between steps. Clients with a short request timeout should reset it on progress or raise it.

Watch a browser / solve a captcha

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, and the page is built around it: a browsers table, then a connected sessions table. fleet slots in use: N/MAX heads the page and 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: they are the second table, 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. A browser held over the CDP lane has no MCP session and so no chikin_identify handle — its handle column reads cdp and attached reads yes (cdp), since an empty cell would read as "nobody is using it". The strikes, respawns and chrome columns are the wedge canary — read them together, as Wedge self-healing explains. Durations read in human units with the exact seconds in the cell's tooltip, and the page refreshes itself every 5s (pausable top-right; paused while the tab is hidden, and it still works with JavaScript off).

Recording (video / GIF)

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.

Pre-authenticated browsers (golden profile)

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-seed

No 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 done

chikin-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.

Drive a browser with Playwright (the CDP lane)

Not everything that wants a logged-in browser is an MCP client. /cdp/<name>/ hands the raw DevTools Protocol to Playwright, puppeteer, or anything else that speaks it — same fleet, same lazy provisioning, same golden seed, no MCP in the path.

import { chromium } from 'playwright-core';

const browser = await chromium.connectOverCDP('http://127.0.0.1:8080/cdp/giard-scrape/', {
  timeout: 120_000,                       // a cold browser takes up to PROVISION_TIMEOUT_SEC
  // headers: { Authorization: `Bearer ${process.env.GATEWAY_TOKEN}` },   // if you set one
});
const context = browser.contexts()[0];    // ← the golden profile. NOT newContext()
const page = await context.newPage();
await page.goto('https://www.ancestry.com/');
# Python reaches the same endpoint
browser = playwright.chromium.connect_over_cdp("http://127.0.0.1:8080/cdp/giard-scrape/", timeout=120_000)
context = browser.contexts[0]

The first request provisions chikin-chrome-giard-scrape from SEED_VOLUME, exactly as a browser tool call does on the MCP side, and it counts against MAX_FLEET like any other browser. bin/chikin-pw <name> warms it first and prints the endpoint, which is the easy way to keep a cold start out of your script's connect timeout.

Things worth knowing before your first run:

  • browser.contexts()[0], never browser.newContext(). The logins live in the browser's persistent profile, which Playwright sees as the default context. A fresh context is a fresh, logged-out one.
  • One driver at a time. A browser being driven over CDP refuses an MCP session and vice versa, both with a 409. The lane is held by the driver, not by the connection, so your own follow-up /json/* requests and any further sockets you open are fine; a driver at another address is turned away. Want two? Use two names — each gets its own clone of the seed.
  • These browsers are headful, under Xvfb, which is the whole anti-detection point. So noVNC works while your script runs: open http://localhost:8080/vnc/<name>/ to watch it, or to clear a CAPTCHA by hand mid-run.
  • The lane is guarded by the same Host check as everything else, by GATEWAY_TOKEN when you set one, and by refusing anything that looks like it came from a web browser: an Origin header, or any Sec-Fetch-* header. No CDP driver sends either; a browser sends Sec-Fetch-* on every request it makes, including the no-cors fetches and navigations that carry no Origin at all.
  • Downloads land in the container, at /tmp/chikin-shared/<name> on the host (see below); download.path() is not available over CDP. For uploads, pass setInputFiles a {name, mimeType, buffer} rather than a host path.
  • An idle driver is still reaped: an open CDP socket counts as attached, and the attached tier is measured against traffic your driver actually sends (ATTACHED_IDLE_TTL_SEC).

Scratch files (per-browser)

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.

Profile volumes and cleaning them up

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-golden carries chikin.fleet=1 exactly 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-snapshot creates the seed with a plain docker volume create, so chikin-seed carries no labels at all: no label filter can reach it, but a bare --all takes it along with everything else. That is the volume new browsers are actually cloned from when SEED_VOLUME is 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-snapshot refuses to run without chikin-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=instance

But 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.


Configuration (fleet)

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, no CDP driver) is reaped. Measured against any MCP traffic, or a CDP driver's own socket 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. An open CDP socket counts as attached too, measured there against bytes the driver actually sends rather than a timer. 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 on the MCP side: the bridge reconnects transparently, though a disposable inst-* browser's profile is discarded with it (logged explicitly). A CDP driver gets no such courtesy — its websocket dies with the container and the script sees the failure. 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_CDP_LANE 1 Serve the CDP lane at /cdp/<name>/ — Playwright, puppeteer and other DevTools-protocol clients, with no MCP in the path. Same lazy provisioning, same golden seed, same MAX_FLEET. 0 answers 404 there and leaves the browsers' CDP reachable only through the MCP endpoint.
CHIKIN_ANTHROPIC_API_KEY (empty) Turns on chikin_achieve: a goal-level tool whose inner loop asks a Claude model which row to do next. Empty means the tool is not offered. A paid credential: one model call per step. /healthz reports achieve: true/false, never the key. Chikin-scoped so a shell's own ANTHROPIC_API_KEY cannot switch it on through compose.
CHIKIN_OPENROUTER_API_KEY (empty) The same, through OpenRouter, used only when CHIKIN_ANTHROPIC_API_KEY is empty. It also reaches TypeSafe's Jev (see CHIKIN_DECIDER_MODEL). Each trace step names the model that actually answered. Chikin-scoped for the same reason.
CHIKIN_DECIDER_MODEL (provider default) The model chikin_achieve asks for each decision. Empty means claude-haiku-5-5 direct, or jev-latest on OpenRouter. Any jev-* makes TypeSafe's Jev choose each row through its own System One API, which OpenRouter serves. Any other id is an OpenRouter chat model, e.g. anthropic/claude-haiku-5.5. typesafe/jev-router is a different thing: a chat model that routes to other LLMs.
CHIKIN_TEXT_MODEL anthropic/claude-haiku-5.5 When Jev decides, the OpenRouter model that writes the text a fill needs (Jev cannot generate text), and that takes over any step Jev is unsure of (operation probability below 0.6). Ignored otherwise.
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.

Checking the effective config

.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 banner

Startup 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.

Wedge self-healing (issue #15)

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:

  1. 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. The child's view is read from the human-readable ## Pages block in its reply, which is the only channel that reaches the gateway under the flags it spawns children with: the child also builds a machine-readable structuredContent.pages, but it copies that onto a tool result only when --experimentalStructuredContent is passed, and the chrome-devtools-mcp binary leaves that flag off — the same in 1.1.1 and 1.6.0, so a version bump does not change it (#75). Only the package's sibling chrome-devtools CLI puts that flag in its defaults. The MCP SDK is not what drops it: it validates structuredContent against an outputSchema when a tool declares one, and never removes the field. So a prose format is load-bearing by default, and it broke — 1.6.0 renders a titled page as <id>: <title> (<url>) instead of <id>: <url>, and a parse that assumed the latter selected nothing, so the watchdog went blind with every test still green. Setting CDM_EXTRA_ARGS=--experimentalStructuredContent is a supported way to put the structured channel back on the wire, and the gateway prefers it whenever it is present — which is why that code path is kept rather than deleted. itest/cdm-wire.mjs proves a real reply still parses to a selected page; gateway/test/cdm-outputschema.test.ts is the CI-side tripwire on upstream declaring a schema.
  2. chikin_reset tool — injected into every tools/list (alongside chikin_identify, see Identify your session first), so the model itself can hard-reset a wedged browser (container recreated, profile/logins preserved) without human help.
  3. Self-healing transports — both the client bridge and the gateway replay the cached initialize over 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.


Chrome container internals

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.

Why the port shuffle

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.


Architecture & platforms

The image is multi-arch:

  • linux/amd64 — Google Chrome stable.
  • linux/arm64 — Debian's Chromium (Google ships no google-chrome-stable for Linux arm64). The User-Agent says Chromium/<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.


Security

  • 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 is internal: true. The CDP lane proxies raw CDP through that same port under the same bearer, and additionally refuses anything a web browser sent (an Origin, or any Sec-Fetch-* header) — a page that reached it would hold a full control channel into a logged-in browser. CHIKIN_CDP_LANE=0 removes the route.
  • 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: true does 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 9CCF , add a DOCKER-USER iptables rule; that's outside chikin's scope.
  • Scoped Docker access. The gateway reaches Docker only through tecnativa/docker-socket-proxy with a read-only socket mount, scoped to containers/volumes/images (+POST). exec, info, swarm, and secrets are denied — verify with the proxy returning 403 on /info and /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 :9222 and noVNC :6080, per ADR 0003, asserted as EXPECTED_PEER_REACHABLE in itest/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.

Renderer sandbox (H1)

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 script

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.

Gateway development

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 and CDP reverse proxies. See gateway/src/ — server.ts (routing/auth), provisioner.ts (Docker lifecycle), bridge.ts (MCP↔stdio pump), cdp.ts (the CDP lane), reaper.ts (idle reclaim). Why the CDP lane is a proxy through the gateway's one published port rather than a published :9222 per container is ADR 0005. Why bridge.ts brings a tab to the front before forwarding a take_screenshot, and why the stuck-call half of that is accepted rather than guarded, is ADR 0006.

Troubleshooting

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). A 409 naming a lane ("is being driven over the CDP lane", "already being driven over the MCP lane") is the same rule across protocols: one driver per browser, so give the other lane its own name.

"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.

License

MIT. See LICENSE.

About

Real (non-headless) Google Chrome in a Docker container, exposing CDP on port 9322

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

0