Devince
AI,  Development,  DevOps

Wieczór, jedna markdownowa specka i lokalny UI do Claude Code gotowy

Author

Bartłomiej Filipiuk

Date Published

Miałem wolny wieczór. Używam Claude Code CLI od kilku miesięcy i w ~/.claude/projects/ uzbierało mi się 56 projektów, setki sesji w formacie JSONL, razem jakieś 800 MB historii. Za każdym razem gdy chciałem wrócić do poprzedniej rozmowy, żonglowałem zakładkami terminala, szukałem po nazwach katalogów kodowanych myślnikami, robiłem grep -r po JSONL-ach. Bezsens.

Tego wieczoru postanowiłem sobie z tym zrobić porządek. Otworzyłem nowy projekt, napisałem jeden markdownowy plik ze speczką (claude-projects-manager-spec.md, około 150 linii) i podałem go Claude'owi jako punkt startowy. Po dwóch dniach miałem działający web UI z wbudowanym terminalem, streamowaniem sesji, live updatami, edytorem CLAUDE.md, defense-in-depth na poziomie którego nie spodziewałem się od lokalnego toola, i repo na GitHubie z 230+ testami.

No i właśnie. Ten post jest o tym jak to się stało, co mnie po drodze zaskoczyło, i dlaczego lokalny web UI na 127.0.0.1 potrzebuje realnego threat modelu.

Od speczki do planu

Speczka była krótka. Trzy kolumny: lista projektów, lista sesji w projekcie, viewer plus terminal po prawej. Chokidar do live updatów. Multi-tab terminal z node-pty. Edytor CLAUDE.md. Koniec. Plus jedno zdanie: "ma być bezpieczne, nie traktuj tego jak lokalny proof of concept".

Tym jednym zdaniem wszystko się zmieniło. Gdyby nie ono, powstałby kolejny dev tool który słucha na 0.0.0.0 bez auth, bo "to tylko localhost". Zamiast tego dostałem osobną fazę 0 na security primitives, fuzz 100 payloadów dla path-guarda, CSP z nonce, CSRF double-submit, audit log z whitelist pól. Security wszedł do architektury od pierwszego commita.

Wrzuciłem speczkę do plan mode. Claude wziął to, zadał cztery serie pytań (deployment model, terminal scope, write operations, stack UI), zrobił research. Potwierdził że Chromium nie wspiera HTTP over Unix socket (co zabiło pierwszą wersję planu), sprawdził wersje node-pty, zweryfikował że flux-2-pro da radę z tekstem w banerze. Zmienił jedną istotną decyzję (z Unix socket na 127.0.0.1 plus ephemeral port plus token) i wypluł plan na 8 faz z security gate do przejścia przed każdym commitem.

Każda faza miała swoje docs/PHASE-N.md z 8-15 checkboxami. Ja sprawdzałem, Claude implementował, testy walczyły o swoje.

Co w środku

Stos jest standardowy, ale dobór jest premeditated:

  • Next.js 15 App Router z custom server.ts. Custom serwer siedzi obok Next-a, nie zastępuje go. Next obsługuje App Router i HMR, my przejmujemy HTTP middleware stack i WebSocket upgrade dla /api/ws/pty oraz /api/ws/watch.
  • node-pty (wariant @homebridge/node-pty-prebuilt-multiarch, z prebuilt binaries, zero node-gyp w instalacji). PTY manager z capem 16 jednoczesnych, rate limit 10 spawnów na minutę i backpressure 1 MB unacked żeby cat bigfile nie wywalił pamięci serwera.
  • xterm.js z addon-fit i addon-web-links. Każda zakładka terminala żyje we własnym PTY, scrollback zachowuje się przy przełączaniu (visibility:hidden trick, bo display:none wywala fit).
  • chokidar obserwuje ~/.claude/projects/ (depth: 2, followSymlinks: false), pushuje przez WebSocket metadane (slug plus sessionId, nigdy content). Sidebar i viewer odświeżają się w mniej niż sekundę po appendzie do JSONL.
  • react-virtuoso dla virtualizacji długich sesji (niektóre mają 2000+ eventów). Shiki dla syntax highlight w blokach kodu. react-markdown plus rehype-sanitize dla assistant content. Nigdy dangerouslySetInnerHTML z user inputem, lint tego pilnuje.
  • CodeMirror 6 dla edytora CLAUDE.md. Atomic write (.tmp plus rename) i detekcja konfliktu przez If-Unmodified-Since. Jeśli plik zmienił się na dysku podczas edycji, UI pokazuje banner "Reload" zamiast nadpisywać.
  • Zustand dla local state, TanStack Query dla cache serwera, shadcn-style komponenty (własne kopie, nie zależność).

Reality check: Chromium zjadł mi cookie

Fazy 0-7 poszły w miarę gładko. Problemy zaczęły się gdy odpaliłem ./bin/claude-ui na swoim produkcyjnym ~/.claude/, poza fixturami w testach.

Pierwsza niespodzianka: Chromium w trybie --app=URL gubił cookie na pierwszej nawigacji. Flow wyglądał tak:

  1. Launcher odpala chromium --app=http://127.0.0.1:PORT/api/auth?k=TOKEN --user-data-dir=<profile>
  2. /api/auth zwraca 302 z Set-Cookie: claude_ui_auth=...; SameSite=Strict
  3. Chromium podąża za redirectem do /
  4. / pokazuje JSON {"error":"unauthorized"}

Wszystko wyglądało poprawnie. Token pasuje, cookie jest w response, redirect idzie do tej samej origin. A jednak: cookie przy requestcie do / nie leci.

Co tu się dzieje? Chromium traktuje pierwszą nawigację w trybie --app=URL jako externally-initiated (brak parent document), co w niektórych flow-ach dropuje SameSite=Strict przy cross-boundary redirect. W zwykłym browserze by działało. Pod --app nie.

Dwie próby fixa:

  • Zmiana na SameSite=Lax. Nie pomogło, Chromium nadal gubił. Docs Chromium mówią o edge case z SameSite=Strict-to-Lax promotion, ale praktyka była taka że cookie nie commitowało się przed drugą nawigacją.
  • Zamiana 302 Found na 200 OK z HTML response który robi <meta http-equiv="refresh"> plus <script>location.replace('/')</script>.

Druga wersja zadziałała. Wyjaśnienie sensowne: 200 HTML response commituje cookie do Chromium storage zanim JS wywołuje nawigację, a sama nawigacja inicjowana z DOM-u jest jednoznacznie same-origin. SameSite nie ma się czego czepić.

Token w URL nadal nie trafia do historii przeglądarki (HTML page od razu robi replace), a Referrer-Policy: no-referrer pilnuje żeby nie wyciekł przez Referer.

Security w liczbach

Skoro defense-in-depth był zdaniem ze speczki, musiał być udokumentowany. docs/SECURITY.md ma 25-punktowy stack obronny z weryfikacją testową każdego punktu. Najważniejsze:

  • Bind tylko 127.0.0.1 na losowym ephemeral porcie (49152-65535). Nigdy 0.0.0.0. Test w CI: lsof -i :$PORT musi pokazać bind na loopback.
  • Token 32-bajtowy z crypto.randomBytes, rotowany przy każdym starcie. Weryfikowany przez crypto.timingSafeEqual. Stare cookie po restarcie dostaje 401.
  • Host header allowlist (tylko 127.0.0.1:PORT i localhost:PORT). Blokuje DNS rebinding nawet gdyby cookie kiedyś wyciekło. Test: curl -H 'Host: evil.com' zwraca 403.
  • CSP z per-request nonce w Next edge middleware.ts. script-src 'nonce-<x>' 'strict-dynamic', bez unsafe-inline. 'unsafe-eval' tylko w dev (Next HMR tego wymaga), w prod strict.
  • CSRF double-submit, czyli drugie cookie, nie HttpOnly, JS czyta i wstawia do x-csrf-token header. Timing-safe compare na każdym POST/PUT/PATCH/DELETE.
  • Path guard dla każdej ścieżki z inputu. fs.realpath plus prefix check: resolved === root || resolved.startsWith(root + sep). Fuzz 100 payloadów w unit testach: URL-encoded ../, null bytes, UTF-8 fullwidth dots, symlink escape, prefix collision typu /root/.claudeEVIL/.
  • Audit log w ~/.claude/claude-ui/audit.log (mode 0600, dir 0700). Whitelist pól: ts, event, sessionId, pid, cwd, shell, cols, rows, path, bytes, writeKind. Nigdy env, tokeny, cookie, treść wiadomości. Pino logger z redact: ['token', 'authorization', 'cookie', '*.env'].

Po co to wszystko na 127.0.0.1? Bo "to tylko localhost" jest mitem. Realne wektory ataku:

  • Malicious strona w tle w Twoim zwykłym Chrome może próbować fetch('http://127.0.0.1:ephemeral/api/...'). Host allowlist plus dedicated Chromium profile nie wpuszczą.
  • DNS rebinding, gdy zewnętrzna strona zmienia A-record swojej domeny na 127.0.0.1 żeby obejść Same-Origin. Host header allowlist blokuje.
  • XSS w treści sesji. Gdyby assistant zwrócił markdown z <script>, bez rehype-sanitize można by wstrzyknąć. Playwright test z <img src=x onerror=alert(1)> w fixture łapie alert listener, zero triggerów.
  • PTY jako RCE, oczywistość. Dlatego cap, rate limit, backpressure, path-guard na cwd, audit log spawn.

Jestem pewien że ten stack wytrzyma typowe ataki lokalnego sąsiada w przeglądarce. pnpm audit --prod --audit-level=high zwraca zero. CI ma grep-forbidden rule który blokuje commit z eval(, new Function(, dangerouslySetInnerHTML z literal stringiem.

Live updates i wielowątkowy terminal

Dwie rzeczy które w codziennym użyciu zmieniły najwięcej:

Live updates. Chokidar obserwuje ~/.claude/projects/ i przez WebSocket pushuje eventy session-added oraz session-updated (tylko metadane, nigdy content). Klient inwaliduje TanStack Query cache. Wynik: uruchamiam claude w jednej karcie terminala, w claude-ui widzę nową sesję pojawiającą się w sidebarze w mniej niż sekundę. Klik niepotrzebny. Eventy są batchowane (okno 100 ms, max 50 na push) żeby burst JSONL appendów nie spamował socket.

Multi-tab terminal. Każda zakładka żyje we własnym PTY. Klik "▶ resume w terminalu" na sesji otwiera nowy tab, spawnuje $SHELL w cwd projektu (wyciętym przez path-guard względem $HOME), po statusie ready wpisuje za mnie claude --resume <id>\r. Nie muszę pamiętać sessionId, nie muszę cd-ować, Claude odpala się na właściwej historii.

Ten drugi punkt miał swojego własnego reality checka. Pierwsza wersja spawnowała PTY z shell: 'claude' i args: ['--resume', id]. Logi zwróciły klasyk: /bin/bash: --resume: invalid option. Bo resolveShell() wymagał absolute path. 'claude' nie było z /, więc fallback do /bin/bash, ale args już zostały. /bin/bash --resume <id> i bash nie zna flagi, więc exit 2. Fix: spawnuj default $SHELL, po ready wpisuj command do stdin. Wtedy claude resolvuje się przez PATH, .bashrc się załaduje, aliasy działają.

Gdzie to jest teraz

Repo jest publiczne: github.com/bartek-filipiuk/claude-ui. 7 faz, 8 tagów phase-*-done, README napisany w English z realistycznym security modelem (wymuszenie: żadne "your data is safe with us", tylko konkretne kontrole z testami).

Banner wygenerowany przez flux-2-pro z krótkim wordmarkiem, minimalist tech, czarne tło, cyan circuit traces, bursztynowy kursor po lewej. Wyszło zaskakująco OK. Flux 2 pro faktycznie radzi sobie z krótkim tekstem bez artefaktów.

Backlog usprawnień jest w IMPROVEMENTPLAN.md, aktywna kolejka w TASKS.md. Dodałem hourly remote scheduler (Anthropic CCR) który co noc odpala Opusa 4.7, czyta TASKS.md, bierze pierwszy nieoznaczony task, implementuje go z testami, commituje, pushuje, odhacza checkbox. 2 taski na godzinę, 9 okien w nocy, budżet 55 minut wall clock na run. Czy zadziała, zobaczę rano. Sama infrastruktura jest prosta: prompt self-contained, TASKS.md jako persistent queue.

Co zostawiam Ci z tego

Kilka rzeczy do przemyślenia:

  1. Speczka ma dwa zdania. "Ma być bezpieczne, nie traktuj tego jak proof of concept" wywaliło całą architekturę na inne tory niż domyślny dev tool. Spędź dwie minuty na sformułowaniu intentu zanim wbijesz w prompt. Claude będzie agresywnie respektował to zdanie przez pozostałe 200 plików.
  2. "To tylko localhost" to mit. Jeśli Twój tool nasłuchuje na loopback i obsługuje shell albo writes do $HOME, potraktuj go jak zdalne RCE dla malicious strony w innym tabie. Host allowlist, CSP z nonce, CSRF, Origin check, wszystko razem.
  3. Reality check zawsze przychodzi. U mnie Chromium --app z cookie, sniff cwd zabity przez 50-kilobajtowe hook attachments, shell: 'claude' fallback na bash. Każdy tydzień przynosi jedno takie. Buduj stos który można debugować (pino z redactem, audit log, playwright console listener) bo inaczej godzinami zgadujesz.
  4. Testy są Twoją redundancją. 230+ unitów plus 38 integration plus 18 e2e plus fuzz 100 payloadów dla path-guarda. Brzmi paranoicznie, ale każdy z tych testów już raz mnie uratował przy refaktorze. CSP nonce integration test złapał regresję którą bym przeoczył ręcznie. Zainwestuj w test infrastructure wcześniej niż myślisz.

Konkluzja

Wyszedłem z wieczoru ze speczką i poszedłem spać. Wróciłem po dwóch dniach do działającego tool'a z testami, repo na GitHubie, schedulerem który sam dopisuje kolejne feature-y w nocy. To dzieje się wtedy gdy Claude Code dostaje jasny intent ("ma być bezpieczne") i konkretny plan podzielony na fazy z security gate. Dobra speczka wygląda skromnie. Jednoznaczność intencji waży tam więcej niż długość i szczegół razem wzięte.

Bo co tu dużo mówić: Claude zrobi to o czym mu powiesz. Pytanie tylko czy powiesz mu co chcesz, czy co myślisz że on chce usłyszeć.