Skip to content

Windows port (Wave 1-6): Zig 0.16 ABI fixes + std.c → std.Io migration#163

Open
lekt9 wants to merge 57 commits into
justrach:adding-extensionsfrom
lekt9:feat/windows-port-wave-1
Open

Windows port (Wave 1-6): Zig 0.16 ABI fixes + std.c → std.Io migration#163
lekt9 wants to merge 57 commits into
justrach:adding-extensionsfrom
lekt9:feat/windows-port-wave-1

Conversation

@lekt9

@lekt9 lekt9 commented May 23, 2026

Copy link
Copy Markdown

Summary

49 commits porting Kuri to Windows on Zig 0.16:

  • Wave 1fd_t typing for stdio + clock_gettime alternatives
  • Wave 1.1 — Zig 0.16 Windows API binding corrections
  • Wave 2 — stub POSIX socket/process/tty paths for Windows
  • Wave 3 — impl-struct pattern for time/threading + gate cmdOpen/repl
  • Wave 4 — gate discoverTabs std.c.write socket loop
  • Wave 5 — batch-port remaining 6 std.c-touching files
  • Wave 6 — migrate cwd*File from std.c.open to std.Io.Dir

Plus parallel-multi-tab tabbed-throttling disable, closeTarget over sibling sessions, and chrome://newtababout:blank for initial tab.

Test plan

  • macOS / Linux: existing tests pass (no regression on POSIX targets)
  • Windows: kuri.exe builds with Zig 0.16 + serves CDP
  • Cross-platform: kuri newtab / setCookie / getCurrentUrl end-to-end

This is the upstream-merge step the unbrowse repo's submodule has been pinned on. Once this merges, the unbrowse .gitmodules (which already points at this repo @ adding-extensions) will resolve cleanly from a fresh clone with the Windows port included.

🤖 Generated with Claude Code

lekt9 and others added 30 commits April 9, 2026 18:10
…/evaluate

1. HTTP read buffer in handleConnection increased from [8192]u8 to [65536]u8.
   This prevents "CDP command failed" when eval results exceed 8KB — the read
   buffer was the chokepoint for incoming HTTP requests including the CDP
   response forwarded back to the caller.

2. /evaluate now reads expression from POST body first (raw text or JSON
   {"expression":"..."}), falling back to ?expression= query param. This
   allows the Node client to send large JS expressions (e.g. the 7.5KB
   interceptor script) via POST instead of URL-encoding them into a query
   string that exceeds OS/proxy URL length limits.

Fixes: unbrowse-ai/unbrowse-dev#408

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Stealth & Anti-Bot:
- Enhanced stealth.js: WebGL/canvas/AudioContext spoofing, chrome.csi stubs
- Chrome 135 UAs, --disable-blink-features=AutomationControlled
- Auto-stealth via Page.addScriptToEvaluateOnNewDocument on startup
- KURI_PROXY env var for residential proxy support
- --no-sandbox Linux-only (fixes justrach#128)
- Successfully bypasses Singapore Airlines Akamai WAF

Bot Block Detection:
- /navigate auto-detects Akamai/Cloudflare/PerimeterX/DataDome blocks
- Returns structured fallback with suggestions and proxy hints

HAR Replay:
- /har/replay endpoint: API map with curl/fetch/python code snippets
- Captures full request headers and POST bodies from CDP events

Security:
- SSRF protection on /navigate via URL validation (fixes justrach#81)
- JSON injection fix: escape all user content in JSON output (fixes justrach#82)

CDP Stability:
- EventBuffer use-after-free fix (fixes justrach#83)
- 500 event headroom (was 100) for heavy SPAs
- Auto-reconnect on WebSocket errors
- Stale WebSocket cleanup in connectWs()

Skills:
- kuri-server skill for HTTP API browser automation
- kuri-browse skill for terminal browsing

Amp-Thread-ID: https://ampcode.com/threads/T-019d70d2-de3a-7398-b899-1d5061a8921d
Co-authored-by: Amp <amp@ampcode.com>
), setsockopt error (justrach#88), HTML entities (justrach#91)

- extractHtmlValue: manual increment fixes adjacent escape skip bug
- doHandshake: validates Upgrade header presence, not just HTTP 101
- setsockopt: returns ConnectionFailed instead of silently continuing
- markdown: added 10 named HTML entities (rsquo, mdash, hellip, copy, etc.)
- 8 new unit tests (250 total, all passing)

Amp-Thread-ID: https://ampcode.com/threads/T-019d70d2-de3a-7398-b899-1d5061a8921d
Co-authored-by: Amp <amp@ampcode.com>
Breaking changes addressed:
- std.net → raw C sockets (TcpStream/TcpServer in compat.zig)
- std.time.timestamp/milliTimestamp/nanoTimestamp → clock_gettime shims
- std.Thread.Mutex/RwLock/sleep → pthread shims
- std.crypto.random → arc4random_buf
- std.fs.File/cwd() → C open/read/write/mkdir/unlink
- std.process.Child.init/run → fork/exec
- std.process.argsAlloc → _NSGetArgc/_NSGetArgv
- std.posix.getenv → std.c.getenv wrapper
- ArrayList.writer() → direct .appendSlice/.print/.append
- std.mem.trimRight/trimLeft → trimEnd/trimStart
- std.heap.GeneralPurposeAllocator → DebugAllocator
- std.http.Client needs io field
- Compile.linkLibrary → root_module.linkLibrary
- std.io.fixedBufferStream → removed

New file: src/compat.zig — centralized 0.16 compatibility shims
Adds compact HTTP snapshots with a11y value/description/state and waits for tab/new hydration before the first session snapshot.
Use the release-channel branch for installers and future tag publishing instead of GitHub Releases.
EventBuffer.push stored the caller's allocator alongside the event.
When the caller was a per-request arena that died after the request
completed, a later flushEventsToHar would call
item.owner.free(item.data) on a dead arena vtable — undefined
behavior, manifesting as a SIGSEGV right after every "HAR flush: N
buffered events" log line.

Fix: dupe the event into the EventBuffer's own long-lived allocator
at push time, free the original from the caller's arena while it is
still alive, and store the buffer's own allocator as the owner. This
decouples buffered-event lifetime from the caller's request scope.

Repro: any browse session that buffers CDP events between requests
(e.g. unbrowse go → close) reliably segfaulted on the next HAR flush.
After this fix, the same chain runs cleanly with no SIGSEGVs.
Resolves 9 conflicts across 3 files by union-merging local features with
upstream API/style changes.

cdp/client.zig:
  Take HEAD's EventBuffer dupe + early-free-on-failure (memory correctness).

chrome/launcher.zig:
  Imports: keep both extensions_mod (local) + compat (upstream).
  Launcher fields: keep state_dir + builtin_ext_path (local) + proxy (upstream).
  Init defaults: same union.
  findChromeBinary: take upstream's findExecutableCandidate(chrome_paths,
    compat.getenv("PATH")) — both helpers exist in the auto-merged tree.

server/router.zig:
  Read buffer: 65536 (local fix for POST body support) with upstream's
    new init signature (stream, io, &buf).
  handleEvaluate: upstream's requireEffectiveTabId for tab resolution +
    HEAD's POST-body / JSON expression / query-param fallback chain.
  Endpoint count test: HEAD's single-column format + /add-init-script
    preserved (local feature).

NOTE: merged code requires Zig 0.16.0 (std.process.Init) — Lewis has 0.15.2
locally; build needs CI or local toolchain bump.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
lekt9 and others added 27 commits May 4, 2026 17:38
Adds a sandboxed JS runtime for running anti-bot / signed-URL / HMAC
bundles outside of Chrome. ~50ms cold start vs ~3s for full Chrome.

The architectural insight: rather than reverse-engineer adversarial
bundle math, be a faithful enough environment that the bundle fetches
its own salts, runs its own computation, and hands us the cookie. The
runtime IS the salt provider, by virtue of being a sufficient browser.

Components:
- src/sandbox/shim.js: ~30KB Web API shim (navigator, document, window,
  screen, location with URL polyfill, performance, crypto.subtle, fetch,
  XMLHttpRequest, localStorage, TextEncoder/Decoder, AudioContext +
  WebGL/canvas fingerprint stubs).
- src/sandbox/runtime.zig: QuickJS wrapper, registers six native bridges
  (__nativeFetch, __nativeNowMs, __nativeRandomBytes,
  __nativeSubtleDigest, __cookieJarGet, __cookieJarSet).
- src/sandbox/network.zig: outbound HTTP via curl-impersonate subprocess
  (fork+execvp, kuri's own pattern — std.process.spawn OOMs on 0.16
  with the io vtable kuri uses). Body via tempfile @path. Response via
  --output tempfile to avoid stdout pipe collection. Cookie jar with
  Set-Cookie parsing and host-domain matching.
- src/sandbox/fingerprint.zig: two builtin Chrome fingerprints (mac ARM,
  Windows), JSON serializer.
- src/sandbox/handler.zig: POST /v1/sandbox/replay request shape +
  response builder.
- src/sandbox_smoke.zig: 8 standalone tests (test-sandbox build step).

Wired into src/server/router.zig as POST /v1/sandbox/replay.

Tests:
- zig build test-sandbox: 8/8 pass.
- Real network round-trip via curl override: 654ms cold, post_eval
  returns parsed JSON from tls.peet.ws including spoofed UA.

curl-impersonate is auto-discovered as `curl_<profile>` (e.g.
curl_chrome131) on PATH, with $UNBROWSE_CURL_IMPERSONATE override for
explicit binary paths.

Plan: docs/deep-reveng.md (in unbrowse repo).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace subprocess fork+execvp(curl) with statically-linked
libcurl-impersonate v1.5.6 (lexiforest fork). Single FFI call into a
self-contained 30MB archive (libcurl + BoringSSL + nghttp2 + brotli +
zstd + libpsl, all baked in). No PATH dependency, no env var, no fork
per request — Chrome 131 TLS handshake out of the box.

Result on tls.peet.ws fingerprint check:
  Plain curl:    JA4 t13d497h2_0d8feac7bc37_7395dae3b2f3 (no h2)
  Kuri sandbox:  JA4 t13d1516h2_8daaf6152771_02713d6af862 (h2)
                 ↑ Chrome 131's actual JA4 fingerprint, with TLS 1.3
                   extensions and HTTP/2 negotiated.

Layout:
- vendor/curl-impersonate/<arch>-<os>/libcurl-impersonate.a
- vendor/curl-impersonate/include/curl/  (vanilla curl 8.10 headers)
- vendor/curl-impersonate/README.md      (refresh procedure per platform)
- src/sandbox/curl_lib.zig               (Zig FFI: easy_init, easy_setopt,
                                          easy_impersonate, easy_perform,
                                          write/header capture callbacks)
- src/sandbox/network.zig                (driver: JSON headers → curl_lib,
                                          Set-Cookie parsing into shared jar)

This commit ships aarch64-macos only (Lewis's dev box). Cross-platform
vendoring (x86_64-macos, aarch64-linux-gnu, x86_64-linux-gnu) is
mechanical — same .a per platform, build.zig already maps target.

Binary growth: 5.4MB → 9.8MB. Acceptable for "Kuri does adversarial
sites natively" positioning.

Build deps: BoringSSL is C++ → libc++ now linked. macOS frameworks
(CoreFoundation, Security, SystemConfiguration), libiconv, libicucore.
Linux: libidn2, libz, pthread, dl, libc++.

Tests: 9/9 sandbox-side pass (8 existing + 1 curl_easy_init smoke).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two additions that complete the cookie pipeline:

1. Sandbox accepts seed_cookies in the POST /v1/sandbox/replay body.
   Each cookie is re-encoded as a Set-Cookie line and fed through
   applySetCookie so the same parsing/scoping rules apply as for
   inbound responses. Populated BEFORE bundle eval so:
     - document.cookie reads see them
     - the bundle's outbound fetch calls send them via Cookie header
     - Set-Cookie responses merge cleanly with the seeded state
   Schema matches src/auth/browser-cookies.ts BrowserCookie shape:
   { name, value, domain, path, secure, http_only, same_site, expires }.

2. CURLOPT_ACCEPT_ENCODING="" tells libcurl to advertise all supported
   encodings AND auto-decode the response. Without this we got gzip/br
   compressed bodies (because curl_easy_impersonate sets
   Accept-Encoding to Chrome's default "gzip, deflate, br, zstd"). The
   bundle would then have to decompress in JS, which defeats the
   purpose. One line, fixes everything.

End-to-end: with cookies extracted from Lewis's Dia session
(via findBestBrowserSession), Reddit's search.json now returns
authenticated JSON Listing — the same endpoint that returned
{"error":"Blocked"} to plain curl earlier in this thread.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds x86_64-macos, aarch64-linux-gnu, x86_64-linux-gnu archives
alongside the existing aarch64-macos. Each is a self-contained
~30-50MB static archive (libcurl + BoringSSL + nghttp2 + brotli +
zstd + libpsl).

build.zig already maps target → vendor dir. Native compile picks
the right archive automatically. Cross-compile from macOS to
non-native targets currently fails on system lib resolution
(iconv, idn2 require target sysroot Zig doesn't ship); enable
those once CI runners build them on the matching platform.

Source: github.com/lexiforest/curl-impersonate v1.5.6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds Windows vendored libs (12MB x86_64-windows + 11MB aarch64-windows).
Unlike Linux/macOS which ship a single self-contained
libcurl-impersonate.a, Windows release ships separate .lib files
(libcurl-impersonate, crypto, ssl, nghttp2, nghttp3, ngtcp2 + crypto,
brotli{common,dec,enc}, zlib, zstd) that need linking together with
Windows system libs (ws2_32, crypt32, secur32, bcrypt, etc.).

Server-side dep is solved. Kuri itself still needs POSIX→Windows port
work in chrome/launcher.zig (CreateProcessW instead of fork+execvp)
before Windows builds can be enabled. See docs/windows-port-plan.md
in the unbrowse repo for the full plan.

Source: github.com/lexiforest/curl-impersonate v1.5.6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Track every __nativeFetch call from sandbox bundles as a RouteRecord
(url, method, status, final_url, content_type, body_excerpt, body_size,
redirected). Surface them as routes_observed in /v1/sandbox/replay
response.

Caller (Node side) feeds these into extractEndpoints + marketplace
publish so every authenticated agent fetch contributes to the
discovery flywheel. Phase 1: Kuri-side tracking + JSON serialization
(this commit). Phase 2: wire publishIndexedSkill on the unbrowse Node
side (separate commit + /v1/skills/from-routes endpoint).

body_excerpt capped at 4KB — enough for extractEndpoints to detect
JSON shapes / API patterns without bloating the response.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add optional `proxy` field to Request and thread it into the libcurl
handle via CURLOPT_PROXY in perform(). libcurl parses user:pass from
the URL itself, so a single setopt covers both unauthenticated and
basic-auth proxies (HTTP, HTTPS, SOCKS5 — scheme dispatch is libcurl's
job).

Pairs with the unbrowse-side wire (SandboxReplayRequest.proxy →
runBundleReplay JSON body → Kuri sandbox handler). Per-request override
is required so different sites can route through different country-locked
residential exits (geo.iproyal.com:_country-us etc).

Falsifier in the unbrowse repo at tests/kuri-proxy-patch-shape.test.sh
guards this commit from rebase drift (9 structural assertions + 4
adversarial mutations, all bite).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
handleEvaluate read request headers twice: once via requireEffectiveTabId
before the body, and again via rememberCurrentTab AFTER readRequestBody.
Zig 0.16 std.http.Server.Request.iterateHeaders() asserts the reader is
still in .received_head state; readRequestBody() (readerExpectNone)
advances it past that, so the second header access panicked with
"reached unreachable code" (SIGABRT), killing the broker on every
/evaluate request and cascading every client call into "Unable to connect".

Resolve the session id once before the body read (arena-duped) and reuse
the snapshot for setCurrentTab instead of re-reading headers. Static scan
confirms handleEvaluate was the only handler with a post-body header read.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Headless Chrome with no URL arg opens its privileged chrome://newtab/
NTP. kuri startup discovery registered it as a tab; it then lingered
forever and became a SILENT wrong-tab fallback whenever a session
tab_id could not be resolved (the observed "snap shows New Incognito
tab" wedge under MCP transport churn). Append about:blank as the
trailing Chrome positional arg so the startup tab is a benign,
non-WebUI placeholder with no target-rotation and no misleading
NTP surface. Verified: faithful in-process gate-flow repro now shows
0 chrome://newtab occurrences across HN/npm/lobste, 0 wedges.
getCdpClient(tab_id) connects a per-target /devtools/page/<id> socket.
closeTarget issued Target.closeTarget over the targets OWN page session
and discarded the response. Chrome tears that socket down as it closes
the target, so the fire-and-forget command races teardown and is
silently dropped: the Chrome tab survives while handleClose still
removeTab()s it. Tabs accumulate across browse sessions and a later
session->tab resolve binds to a stale leftover tab (observed in the
MCP gate: probe 006 wikipedia session resolved live to probe 004s
lobste.rs leftover tab; Chrome :9222 held 4 accumulated targets).

Issue closeTarget over a SIBLING tab session (outlives the close) and
confirm the response, mirroring how Target.createTarget reliably issues
over a non-self session. Fall back to the targets own session only when
it is the sole tab. Distinct from the NTP-startup (launcher about:blank)
and drift-adopt fixes; this is the close()-never-actually-closes layer.
Headless Chrome renderer-backgrounds and timer-throttles non-foreground
targets, so concurrent snap/eval across N tabs starved all but the
active one (empty a11y tree). Adds the standard Playwright/Puppeteer
flags: --disable-background-timer-throttling,
--disable-backgrounding-occluded-windows, --disable-renderer-backgrounding,
--disable-features=CalculateNativeWinOcclusion. Generic, not a per-site
hack. Required for the parallel gate collector (.bench-gate falsifier).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ime alternatives

GitHub Actions windows-latest native build (unbrowse-dev kuri-vendor.yml run 26162544394, job 76957890664) surfaced 12 Zig 0.16 compile errors when building kuri natively on Windows. Wave 1 closes 5 of those by gating the POSIX-only stdio + time syscalls behind comptime-known `is_windows` and routing through portable equivalents:

- compat.zig L1-22 (Time): timestampSeconds / milliTimestamp / nanoTimestamp now use std.time.* on Windows (portable Zig stdlib); POSIX path unchanged. Removes clock_gettime references for the Windows target — clock_gettime's signature is incompatible with the x86_64_win calling convention in Zig 0.16's libc stub.

- compat.zig writeToStdout/writeToStderr: GetStdHandle + kernel32.WriteFile on Windows; POSIX std.c.write unchanged. fd_t is *anyopaque on Windows, so `1` and `2` as comptime_int can't be passed to std.c.write — the kernel32 path takes a real HANDLE.

- compat.zig stderrIsTty (new): GetFileType(handle) == FILE_TYPE_CHAR (0x02) on Windows; std.c.isatty unchanged on POSIX.

- browse_main.zig L683: replace direct `std.c.isatty(2) != 0` with `compat.stderrIsTty()` so the same source compiles on both targets.

This wave does NOT yet address the remaining 7 errors (kuri-fetch, merjs-e2e, kuri-agent compile failures + the fork/connect/pipe/dup2/socket sites in compat.zig L238-356). Those are larger redesigns and ship in Wave-2.

Verify:
- `zig build -Doptimize=ReleaseSafe` on macOS darwin-arm64 exits 0 (verified locally) — no regression to the working lane.
- After push: trigger unbrowse-dev kuri-vendor.yml with `kuri_ref=feat/windows-port-wave-1`. Expect the windows-x64 row to advance past the 5 errors closed here and surface only the remaining 7 for the next wave.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wave-1 used std.time.nanoTimestamp and std.os.windows.GetStdHandle, neither of which exists in Zig 0.16. Replace with explicit `extern "kernel32"` bindings in a comptime-gated `win` namespace so:
- The bindings only exist when `is_windows == true`; POSIX builds get an empty struct.
- `nanoTimestamp` uses QueryPerformanceCounter + QueryPerformanceFrequency, computed in i128 to avoid overflow.
- `milliTimestamp` derives from `nanoTimestamp` for monotonicity consistency.
- `writeToStdout`/`writeToStderr`/`stderrIsTty` use the manually-declared GetStdHandle/WriteFile/GetFileType (Zig 0.16's std.os.windows.kernel32 is nearly empty and doesn't ship those bindings).

Still pending (next CI run will surface, Wave-2 ships):
- compat.zig fork/pipe/dup2/connect/socket/read sites — need comptime-gated impl-struct pattern (the same shape used for the `win` namespace at the top)
- agent_main.zig:1344 + fetch_main.zig:206 — direct std.c fd_t mismatches in caller files

Verify:
- `zig build -Doptimize=ReleaseSafe` on macOS darwin-arm64 exits 0 locally — Wave-1.1 doesn't regress the working lane.
- Next CI re-run will surface only the remaining errors for Wave-2 to target.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wave-1.1 surfaced the remaining 7 Zig 0.16 windows-x64 compile errors as POSIX-only sites in compat.zig + agent_main.zig + fetch_main.zig. Wave-2 gates each with `if (comptime is_windows)` early-return so the type-checker accepts the file on Windows while POSIX builds keep the real implementation.

compat.zig
- runCommand: @Panic on Windows (Wave-3 will ship CreateProcessW + pipe redirection).
- isPortInUse: returns false on Windows (Wave-3: WSAStartup + WSASocket + connect).
- TcpStream.{close,writeAll,read,write,setSockOpt}: NotImplementedOnWindows error / no-op.
- tcpConnectToIp4, tcpListen, TcpServer.accept: NotImplementedOnWindows.
- All `_ = x;` parameter discards removed — Zig 0.16 flags them as `pointless discard` because the function actually uses the param in the POSIX branch.

agent_main.zig
- fetchChromeTabs: returns NotImplementedOnWindows on Windows; POSIX path unchanged. Wave-3 routes via compat.tcpConnectToIp4 once that has a Windows impl.

fetch_main.zig
- L206 `std.c.isatty(2)` → `compat.stderrIsTty()`. Same pattern as browse_main.zig:683 from Wave-1.

Verify:
- `zig build -Doptimize=ReleaseSafe` on macOS darwin-arm64 exits 0 locally (verified) — no POSIX regression.
- After push: kuri-vendor.yml windows-x64 row should advance past the type-checker. Runtime-on-Windows will panic on subprocess/socket use until Wave-3 ships the real impls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ding + gate cmdOpen/repl

Wave-2's `if (comptime is_windows) return X;` pattern doesn't comptime-fold POSIX std.c.clock_gettime / std.c.nanosleep extern decls — Zig still type-checks the un-taken branch and fails because timespec's parameter chain bottoms out at `void` on Windows. Switch to the proven impl-struct pattern (already used at the top of compat.zig for the `win` extern bindings).

compat.zig
- `time_impl = if (is_windows) struct { timestampSeconds/milliTimestamp/nanoTimestamp } else struct { ... POSIX ... }` — Zig instantiates only the chosen branch; the un-chosen struct body is never type-checked. Self-references inside the struct use `@This().milliTimestamp()` to avoid the ambiguity with the file-level wrappers.
- Same pattern for `thread_impl` (threadSleep): Sleep on Windows, std.c.nanosleep on POSIX.
- `win.Sleep` declared as `extern "kernel32" fn Sleep(DWORD) void`.

agent_main.zig
- `cmdOpen` Windows branch: early-return with fatal — fork+execvp is the wrong primitive on Windows. Wave-4+ will ship CreateProcessW with the same arg-marshalling.

browse_main.zig
- `repl` Windows branch: early-return — stdin fd_t differs on Windows (HANDLE vs c_int). Non-interactive flows unaffected.

Verify:
- `zig build -Doptimize=ReleaseSafe` on macOS darwin-arm64 exits 0 (verified) — POSIX path unchanged.
- After push: kuri-vendor.yml windows-x64 row should clear the 5 remaining errors (clock_gettime cascade, agent_main fork, browse_main fd_t). Likely surfaces vendored quickjs.zig "unused local constant" + any remaining direct std.c sites for the next wave.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…p for Windows

discoverTabs is the next file in the kuri-windows std.c-namespace-poison
inventory after Wave-3. It uses raw std.c.write on a socket fd for the
Chrome CDP /json/list HTTP request — same pattern as agent_main.zig
fetchChromeTabs (gated Wave-2) and the compat.zig TcpStream methods
(gated Wave-2).

Early-return NotImplementedOnWindows on Windows; POSIX path untouched.
Wave-4+ will ship compat.socketWrite (WSASend on Windows) so callers
like this can stay portable.

Verify: zig build -Doptimize=ReleaseSafe on darwin-arm64 exits 0
(verified locally). After push: kuri-vendor.yml windows-x64 row should
clear one more error from the 3-remaining set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…indows

Closes the std.c-namespace-poison inventory documented in the kuri-windows-port handoff. Each file's std.c-touching function gets an early-return on Windows via comptime gate. POSIX path unchanged.

Per-file:
- chrome/launcher.zig isExecutablePath: returns false on Windows (POSIX access/chmod/fork chain).
- storage/local.zig deleteTreeAbsolute: noop on Windows (POSIX fork+exec for rm -rf).
- storage/auth_profiles.zig listProfiles + deleteTreeAbsolute: empty result / noop (POSIX opendir+fork).
- crawler/validator.zig posixPathIsSymlink: returns false on Windows (POSIX fstatat).
- cdp/websocket.zig: module-level c_connect extern wrapped in posix_net comptime struct (only declared on POSIX); WebSocketClient.connect/close/writeAll early-return ConnectionFailed/WriteFailed on Windows.
- agent_main.zig: connect extern wrapped same way + the call site uses posix_net.connect.

After Wave-5, the only references to std.c.* in kuri source are inside compat.zig (already impl-struct-gated via Wave-3) and inside per-fn POSIX-only branches that are never reached on Windows. The kuri-vendor.yml windows-x64 row should clear all 3 remaining errors from Wave-3.

Verify: zig build -Doptimize=ReleaseSafe on darwin-arm64 exits 0 (verified locally).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…(Zig 0.16 Windows ABI fix)

Wave-5 closed leaf std.c usage but compat.zig itself called std.c.open at
cwdCreateFile/cwdReadFile/cwdWriteFile. The Windows ABI poison (`parameter
of type void not allowed in calling convention x86_64_win` in std/c.zig's
extern open() decl) propagated through every transitive caller —
kuri-vendor.yml run 26247053013 (2026-05-21) confirmed windows-x64 still
failed on `cwdWriteFile referenced from saveSession/extractBuiltinExtension`.

Wave-6 swaps to Zig 0.16's portable std.Io.Dir API:

  pub fn cwdReadFile(allocator, path, max_size) ![]u8 {
      var threaded: std.Io.Threaded = .init_single_threaded;
      const io = threaded.io();
      return try std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(max_size));
  }

  pub fn cwdWriteFile(path, data) !void {
      var threaded: std.Io.Threaded = .init_single_threaded;
      const io = threaded.io();
      try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = path, .data = data });
  }

std.Io.Dir.cwd() dispatches per-platform: kernel32 (WriteFile/ReadFile)
on Windows, posix syscalls on darwin/linux. No libc extern dependency,
no variadic-after-void parameter, no x86_64-win ABI issue.

cwdCreateFile (fd-returning helper) deleted — three call sites
(agent_main:793, fetch_main:160, storage/local:68) migrated from the
old `cwdCreateFile -> fdClose -> fdWriteAll` triplet to direct
`compat.cwdWriteFile(path, data)`. Collapses 3 lines of fd plumbing
into one portable call at each site.

API reference: confirmed via deepwiki ziglang/zig. Zig 0.16 deprecated
std.fs.cwd() in favor of std.Io.Dir.cwd() with required io: Io param
from std.Io.Threaded.init_single_threaded.io().

Local darwin-arm64 verify: `zig build -Dtarget=native-native` → green
(kuri, kuri-agent, kuri-fetch binaries built successfully).
Windows-x64 verify pending — push, bump unbrowse submodule SHA, fire
kuri-vendor.yml.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add src/ffi.zig + a `zig build ffi` target that emits libkuri_ffi (.dylib/.so),
exporting a stateless C-ABI fetch path:
  kuri_fetch(url, mode) -> NUL-terminated bytes (markdown|html), kuri_free, kuri_ffi_abi_version

No server, no Bridge, no shared state — reuses validator.validateUrl (SSRF guard)
+ http_fetch.fetchHttp + markdown.htmlToMarkdown. Pure-Zig deps + spawned curl;
needs no quickjs / curl-impersonate linkage. Lets a host (e.g. Bun bun:ffi
dlopen) drive kuri's fetch/render in-process instead of running the long-lived
kuri server.

Verified: built native (aarch64-macos, 4.9MB .dylib); Bun FFI dlopen → kuri_fetch
on https://example.com → markdown rendered in-process in ~2.5s.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the Windows port of the main `kuri` broker exe and `kuri-agent`,
which previously failed to cross-compile to x86_64-windows-gnu. Native
darwin/linux behavior is unchanged (verified: Chrome launch + CDP + /health
green on darwin-arm64).

- chrome/launcher.zig: replace the POSIX fork()/execvp() Chrome spawn with the
  cross-platform Io process API (std.process.spawn → fork+exec on POSIX,
  CreateProcessW on Windows; stdio .ignore = /dev/null·NUL). Store the
  std.process.Child and kill()/wait() it portably.
- compat.zig: Zig 0.16 removed std.Thread.Mutex/RwLock (now io-parameterized
  std.Io.Mutex) and Windows has no libc pthreads, so back PthreadMutex/RwLock
  with an atomic spin-mutex on Windows; keep the exact pthread ABI on POSIX.
- cdp/client.zig, cdp/websocket.zig, server/router.zig: guard the remaining
  POSIX-only socket calls (setsockopt/read) behind comptime is_windows so they
  type-check; the CDP websocket transport stays a no-op on Windows pending a
  winsock port (connect() already returns ConnectionFailed there).
- sandbox/curl_lib.zig: return NotImplementedOnWindows before any curl extern so
  libcurl-impersonate symbols stay unreferenced (the vendored Windows archives
  are MSVC-ABI, incompatible with the -gnu cross-target); the sandbox falls back
  to subprocess curl as designed. CDP browse does not use this path.
- storage/auth_profiles.zig: fix the empty-slice return literal on Windows.
…e on Windows)

The CDP websocket client (src/cdp/websocket.zig) was POSIX-only — connect()
returned ConnectionFailed on Windows, so kuri.exe could link and serve /health
but not talk to Chrome. Implement the transport over winsock (ws2_32):

- websocket.zig: add minimal ws2_32 externs (WSAStartup/socket/connect/send/
  recv/setsockopt/closesocket) — Zig 0.16's std omits these. connect() now opens
  a real winsock socket on Windows (POSIX path unchanged); writeAll/rawRead/close
  use send/recv/closesocket. The SOCKET handle is stashed in the existing
  `fd: std.posix.fd_t` field via int↔ptr casts, so the struct shape is identical
  across platforms. Windows SO_RCVTIMEO is a DWORD-millis, handled accordingly.
- build.zig: link ws2_32 for every Windows binary that pulls in the CDP path.

Verified: kuri.exe cross-compiles with winsock linked (PE32+); native darwin
CDP-websocket path still connects to real Chrome (kuri launches Chrome, /health
reports a discovered tab) — the shared handshake/frame logic + refactored
connect() are intact. Live Windows browse runtime is CI-gated (test-windows.yml).

Next: discoverTabs (HTTP /json/list) winsock port for Windows startup tab
discovery; currently NotImplementedOnWindows (CDP target discovery still works
over the websocket).
…TA%)

kuri.exe started and served /health on Windows but failed at launchChrome:
"no Chrome binary found". chrome_paths had only macOS + Linux names, and
isExecutablePath short-circuited to false on Windows (it's a POSIX access(X_OK)
probe). Fix both:

- chrome_paths: add a .windows arm (Program Files / Program Files (x86) /
  Chromium standard install locations).
- isExecutablePath: on Windows, probe existence via compat.cwdAccess (mingw
  access(F_OK)) — there is no exec bit; an .exe is runnable if it exists.
- findChromeBinary: also check the per-user %LOCALAPPDATA%\Google\Chrome
  install (a runtime path, not a compile-time constant), into a file-scoped
  buffer so the returned slice stays valid.

Verified: kuri.exe cross-compiles (PE32+); native darwin Chrome discovery +
launch unchanged (/health → tabs:1). Surfaced by the windows-latest E2E run,
which got kuri.exe to start + listen but could not find Chrome to launch.
On Windows kuri.exe found + launched Chrome but hung in startup: waitForDebuggerUrl
loops on httpProbe → compat.tcpConnectToIp4/TcpStream, which were stubbed
NotImplementedOnWindows. So it never read Chrome's /json/version, never got the
websocket debugger URL, and exited before serving /health.

Implement winsock (ws2_32) in compat.TcpStream.{close,read,write,writeAll} and
tcpConnectToIp4 — the shared loopback-TCP primitive used by the CDP HTTP probes
(and discoverTabs' sibling paths). Mirrors the websocket.zig winsock client;
SOCKET stashed in the fd_t field via int↔ptr. ws2_32 already linked in build.zig.
POSIX path unchanged.

Verified: kuri.exe cross-compiles (PE32+); native darwin startup + /health
unchanged (tabs:1). Surfaced by windows-latest: "launched Chrome on CDP port
9222" then silence — the probe to read the debugger URL was the gap.
…completes

On Windows kuri.exe got Chrome's CDP endpoint (winsock probe works) but then
aborted: main.zig does `try discoverTabs(...)` and discoverTabs returned
NotImplementedOnWindows (it used std.Io.net + raw std.c.write/std.posix.read).

Rewrite discoverTabs' GET /json/list over compat.tcpConnectToIp4/TcpStream — the
now-winsock-capable cross-platform loopback TCP primitive (same one httpProbe
uses). Removes the Windows stub; POSIX behavior unchanged.

Verified: kuri.exe cross-compiles (PE32+); native darwin "startup discovery
registered 1 tabs" + /health tabs:1 unchanged. Surfaced by windows-latest:
"CDP endpoint: ws://..." then NotImplementedOnWindows at router.zig discoverTabs.
…ng logins

Visible-mode Chrome launched with one shared `$HOME/.kuri/chrome-profile`. With
concurrent sessions (each on its own CDP port — e.g. interactive logins across
parallel browse sessions), Chrome's SingletonLock + cookie-DB clobber-on-close
made one session overwrite another's login: the "logins constantly purged /
logged out" symptom, and a gap in per-session isolation.

Use a per-port profile (`chrome-profile-{cdp_port}`) so concurrent instances are
isolated; cross-session cookie persistence stays the auth-profile vault's job
(authProfileLoad on browse_go re-injects each domain's cookies). Compile-verified
(zig build; the per-port string is in the kuri exe).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ogin-purge)

Root-caused from live vault state: saveProfile writes the keychain entry first
(keychainUpsert) and the meta file second (writeMetaFile). If the meta write
fails or is later lost, the cookies are orphaned — they sit in the keychain but
loadProfile hard-required `readMetaFile` and threw, so auth was never restored
and the user appeared logged out though the cookies were right there. Confirmed
on a real machine: github.com + figma.com had keychain entries but NO meta file
(310 keychain entries vs 5 meta files).

loadProfile now falls back to a direct keychain read by the original name when
the meta is missing (default keychain backend), recovering the orphaned cookies.
No-op on the file backend. Compile-verified (zig build).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants