Skip to main content

neko

One real Chromium desktop that a human and an agent share. The human watches and clicks it in a browser tab; an agent drives the same tabs over CDP. Because the profile persists, a human can log in by hand — 2FA included — and an agent can then act as that logged-in user without ever holding the password.

CategoryAutomation
Deploy./uis deploy nekoopt-in only, in no stack
Undeploy./uis undeploy neko — keeps the profile volume
Verify./uis verify neko
Depends onNothing
Required byNothing — consumers opt in
Imageghcr.io/m1k1o/neko/chromium pinned to 3.1.5
Default namespacebrowser
Web viewhttp://neko.localhost — internal only
CDP:9222 on the LAN and in-cluster — token-gated (X-Neko-Token header)

neko or browserless?

Both are browsers. They make opposite trades, and picking the wrong one is the usual first mistake.

browserlessneko
Natureheadless, throwawayinteractive, watchable
Identitynone — blank every timepersistent profile; logins survive
Driversagents onlyhuman + agents, same session
Concurrencymany isolated contexts in parallelone shared session, single-writer
Reach for it whenrendering JS pages, scraping, synthetic checksa task needs the human's own login

The rule: many blank browsers in parallel → browserless. One logged-in browser, shared → neko.

A login-walled task on browserless has no session to use. A fifty-page parallel scrape on neko serialises behind one profile and blocks the human out of it.

Cheapest tool that works, in order:

  1. plain HTTP — the page renders without JavaScript. Free.
  2. browserless — JavaScript pages, no login needed. Cheap, parallel.
  3. neko — only when the human's login must act. Scarce: one shared session.

Escalate only when the level below fails.

Read this before you deploy it

neko is not like the other UIS services, and it is opt-in for these reasons.

CDP is full browser control — cookies included. Anything that presents the token acts as whoever the browser is logged in as.

The token is the only guard. neko is deliberately reachable on the LAN so that agents, other services and the test harness need no Tailscale operator — which means the network is not a guard here. On the reference installation it was: CDP was tailnet-only. A NetworkPolicy cannot replace that, because it cannot distinguish an authorised LAN device from any other LAN device.

So treat NEKO_CDP_TOKEN exactly like the logins it protects. ./uis verify neko check F asserts an anonymous request is refused; if that check ever passes anonymously, every device on the LAN is logged into everything this browser is.

The profile volume is a live credential store. Its blast radius is everything you log into. It is deliberately not backed up — a backup is a second copy of that blast radius, and re-login is cheap.

Never run an agent task while a password is being typed. The human logs in; then the agent acts. An agent driving mid-login can submit the form, navigate away, or read the field.

Single writer. Two drivers on one login collide.

Deploying

neko is in no stack, so ./uis stack install can never start it. It also ships commented out of enabled-services.conf, so ./uis deploy skips it. The only way it starts is naming it:

./uis deploy neko
./uis verify neko

Then open http://neko.localhost and log in with the password from the secret:

kubectl get secret urbalurba-secrets -n browser \
-o jsonpath='{.data.NEKO_MEMBER_MULTIUSER_USER_PASSWORD}' | base64 -d
The shipped password is a LocalDev default

LocalDevNekoUser456 keeps Rancher Desktop zero-config. It is a working password for a browser holding live sessions — change it for anything beyond your own laptop, in .uis.secrets/secrets-config/00-common-values.env, then ./uis secrets apply.

How an agent drives it

The recommended path is @playwright/mcp pointed at neko's CDP endpoint — the same mechanism ./uis browserless verify-session exercises, and proven against neko's locked-down Chromium on the reference installation.

TOKEN=$(kubectl get secret urbalurba-secrets -n browser \
-o jsonpath='{.data.NEKO_CDP_TOKEN}' | base64 -d)

claude mcp add neko -- npx -y @playwright/mcp \
--cdp-endpoint "http://neko.browser.svc.cluster.local:9222" \
--cdp-header "X-Neko-Token: $TOKEN"

From the LAN, swap the endpoint for the Service's external address (kubectl get svc neko -n browser).

The token is a header, and it has to be

A CDP client first GETs /json/version, then opens the webSocketDebuggerUrl that response hands back. Chromium generates that URL, and it carries no query string — so a ?token= would authenticate the discovery request and then fail the websocket upgrade, which looks like the browser being down. A header applies to both. @playwright/mcp sends it with --cdp-header; Playwright itself with connectOverCDP(url, { headers }).

Raw CDP over the websocket, with flattened sessions, remains the low-level fallback — set the same header on the handshake.

Reading pages: screenshot plus vision beats DOM selectors on sites that hide content in shadow DOM.

Exposure — LAN is the base, tailnet is optional

The web view is HTTP plus a websocket and reaches you through Traefik like every other UIS service. WebRTC media cannot use that path — it needs raw TCP.

neko advertises addresses to the viewer through ICE, and the browser connects to exactly those. So an advertised address must be one the viewer can reach.

WhoHowNeeds
agents, other servicesin-cluster neko.browser.svc.cluster.localnothing
agents, the test harnessthe LAN address, :9222 with the tokennothing
a human, locallyhttp://neko.localhost (through Traefik)nothing
a human, remotelythe tailnet overlayTailscale operator

A plain LoadBalancer on the node's own address is the base, and it works on every cluster UIS targets — no Tailscale operator, no CNI-specific feature. The node's InternalIP is discovered at deploy time and always advertised, which is why the default needs no configuration.

The LoadBalancer carries CDP and media, not the web view

It deliberately does not ask for port 80. On a single-node cluster Traefik's own svclb already binds host port 80, so requesting it leaves the LoadBalancer Pending forever — and because all of a Service's ports share one svclb pod, that collision would take CDP and media down with it. The web view does not need it: it reaches users through the IngressRoute, like every other UIS service.

The tailnet is an overlay, never a replacement. Enabling it adds a second Service for the web view; the LAN path keeps working. In .uis.extend/neko-exposure.yaml:

tailnet_overlay: true
hostname: "neko"
extra_advertise_addresses: ["100.97.5.40"] # for MEDIA over the tailnet
A remote viewer needs the overlay address advertised too

The web view works over the tailnet as soon as the overlay exists. Media does not, until that tailnet address is in extra_advertise_addresses — ICE only offers what it is told. A tailnet IP cannot be discovered before its LoadBalancer exists, so the deploy prints the address and re-running picks it up.

Until then a remote viewer sees the page load and the screen stay black.

The advertised port must equal the reachable port

The Service port and NEKO_WEBRTC_TCPMUX are deliberately the same number. A Service that renumbered it would make neko advertise a port the viewer cannot open — and the symptom is a black screen with everything reporting healthy: page loads, pod Ready, /health 200. Check E asserts they agree.

Verifying

./uis verify neko
Check
AWeb view healthy (/health on 8080)the human's door
BCDP reachable through the Servicethe agent's door
CDevTools policy override live, and CDP lists page targetsthe invisible one
DProfile volume bound and writablelogins can persist
EAdvertised WebRTC port equals the reachable portmedia path consistent
FAnonymous and wrong-token CDP requests get 401the guard

C is why the playbook exists. Upstream neko ships DeveloperToolsAvailability: 2 in its Chromium policy, under which Target.attachToTarget is silently refused and page commands are swallowed. /json/version keeps answering 200, the pod stays Ready, checks A, B, D and E all pass — and no agent can drive the browser. UIS overrides that one key to 1 and check C asserts the override is live in the running container.

The verify does not prove a human can see video; that needs a real WebRTC client. Check E proves the advertised values are consistent, not that media flows.

Troubleshooting

The page loads but the screen is black. The media path, not the web view. Check that the advertised port equals the reachable one (./uis verify neko check E), and that nothing between you and the cluster blocks that TCP port.

An agent connects but nothing happens — no error. The DevTools policy. Confirm with check C; if the ConfigMap was edited, the pod must restart to reload the policy.

CDP returns 401. The token is missing or wrong, and it must be the X-Neko-Token header — a query parameter authenticates discovery and then fails the upgrade.

CDP refuses to connect at all. The cdp sidecar, not the browser. Chromium binds its debug port to 127.0.0.1 and refuses non-local binds, so that sidecar both re-exposes it and enforces the token:

kubectl get pod -n browser -l app=neko \
-o jsonpath='{.items[0].status.containerStatuses[*].name}{"\n"}'

Both neko and cdp must be ready.

Logins vanished after a restart. The profile volume. Check it is Bound and writable — a bound-but-unwritable volume means fsGroup is not 1000.

Everything is gone and I want it gone. That is the kill switch working:

kubectl delete pvc neko-profile -n browser

./uis undeploy neko deliberately does not do this — an accidental undeploy should not log you out of everything.