Imported from zeevrussak/z-api-proxy (
AGENTS.md). Install upstream withnpx skills add zeevrussak/z-api-proxy. Copyright stays with the author.
AGENTS.md
Guide for working in the z-api-proxy codebase. Focuses on non-obvious knowledge that isn't immediately apparent from a single file read.
What This Is
A Windows system-tray reverse proxy that sits between Cursor (the AI editor) and the z.ai API. Its sole job is bidirectional model-name rewriting: Cursor sends names like z.ai/glm-5.2, the proxy rewrites them to glm-5.2 on the way upstream, then rewrites them back to z.ai/glm-5.2 in responses so Cursor recognizes them. It is Windows-only by design (system tray, user32.dll MessageBox, APPDATA env var, NSIS installer).
Commands
# Full release: Go binaries (amd64+arm64) + MSIs + NSIS installer → releases/
# Reads VERSION file, injects version into binary via ldflags
build.bat
# Vet (there is no linter configured)
go vet ./...
# Smoke test (integration, requires Python 3 + a running proxy instance)
python test_smoke.py
# Node.js unit tests for the Cloudflare Worker script (worker.js), executed
# for real under Node — not re-implemented in Go. Catches JS-runtime bugs
# (bad scoping, undefined vars) that Go-side worker_test.go cannot, because
# that file only tests a Go re-implementation of the JS logic. Requires
# Node.js 18+, no npm dependencies.
node --test internal/worker/worker.test.mjs
# Build MSI per-arch (requires WiX v4+ as dotnet tool: dotnet tool install -g wix)
wix build installer.wxs -arch x64 -d MsiVersion=1.0.0 -d DisplayVersion=1.0.0-alpha \
-d BinPath=build/amd64/z-api-proxy.exe -d UpgradeCode=18CAB0AD-AF9E-4C0B-AD01-99EF83004F7C \
-o releases/z-api-proxy-1.0.0-alpha-amd64.msi
The app ships as native binaries for both amd64 and arm64. The NSIS installer detects the host architecture at install time via PROCESSOR_ARCHITEW6432. The MSIs are per-architecture. Both produce Start Menu shortcuts, ARP registry entries, and launch after install.
build.bat also generates releases/checksums.txt (SHA-256 of every .msi/.exe in releases/, via certutil -hashfile). You must upload checksums.txt as a release asset alongside the MSIs/installer when publishing to GitHub — updater.go's DownloadAndInstall looks for it by that exact name in the release and verifies the downloaded MSI's hash before running msiexec. A release without it still updates fine (verification is skipped with a log warning, for backward compat with pre-existing releases), it just isn't integrity-checked.
Version management: the VERSION file (e.g. 1.0.0-alpha) is the single source of truth. build.bat reads it and injects it into the Go binary (-X main.version=...), MSIs (display name + numeric version), and NSIS installer. MSI ProductVersion strips the pre-release suffix (1.0.0-alpha → numeric 1.0.0).
Release artifacts land in releases/ (gitignored): z-api-proxy-{VERSION}-amd64.msi, z-api-proxy-{VERSION}-arm64.msi, z-api-proxy-{VERSION}-setup.exe (NSIS).
The smoke test is not a unit test. It starts a mock upstream in-process (Python http.server), assumes the real proxy is already running on 127.0.0.1:8787, and asserts the forward/reverse rewriting works end-to-end against a config that maps z.ai/glm-5.2 → glm-5.2. If you change rewriting logic, run the proxy first, then the smoke test.
Configuration — The #1 Gotcha
The config.toml in the repo root is a template, NOT the file the app reads.
At runtime the app loads config from %APPDATA%\Z-API-Proxy\config.toml (see config.DefaultConfigPath() / AppConfigDir()). On first launch, if that file is missing, config.CreateDefault writes a hardcoded default to that location. The repo config.toml is purely a reference copy.
Consequences:
- Editing the repo
config.tomlhas no effect on a running app. - The tray "Configure..." menu item opens the APPDATA path in Notepad, not the repo path.
- Config changes are picked up via mtime polling (5s interval) in
Manager.watch— no restart needed, and the proxy callsmanager.Get()on every request to read the latest.
Architecture & Control Flow
main.go ──> config.Manager (hot-reload, atomic.Pointer)
──> counter.Counter (atomic int64 handled/rejected)
──> proxy.Proxy (implements http.Handler)
──> http.Server (runs in goroutine)
──> tray.Run() (BLOCKING — main loop lives here)
──> tunnel.Manager (cloudflared subprocess, mutex-protected)
──> updater (GitHub release checker + MSI installer)
systray.Runblocks inmain. The HTTP server runs in a goroutine. The app exits when the user clicks tray → Exit (systray.Quit), which triggers graceful server shutdown.- Concurrency is handled with
sync/atomicfor config/proxy/counter; the tunnel package uses async.Mutex(manages a subprocess with multi-step start/stop). Preserve the appropriate pattern per package. - The tray spawns goroutines:
updateTooltip,updateIcon,handleMenu, andcheckForUpdates(one-shot on startup).
Request flow (proxy.ServeHTTP)
- Read full request body → forward-rewrite model name → forward upstream.
- Path stripping:
/v1prefix is stripped before forwarding, because the upstreambase_urlalready ends in/api/paas/v4. - Hop-by-hop headers are dropped (
hopHeaderslist). - If
upstream.api_keyis set, it overrides theAuthorizationheader; otherwise the client's key passes through. - Response branch:
text/event-stream→ line-by-line SSE rewrite + flush; anything else → buffered regular rewrite with recalculatedContent-Length. - Error state (
hasErratomic) flips on and is reflected in the tray icon.
Rewriting Implementation — Byte-Level, Not JSON
Model rewriting is done with bytes.ReplaceAll on string fragments, not via JSON (un)marshalling. This is a deliberate performance choice but has consequences:
- It matches
"model":"X"and"model": "X"(both with/without space after colon). - Response rewriting (
rewriteModelFields) also rewrites the"id"field, trying both:and:separators. - SSE lines are only rewritten if they start with
data:(after trim) and do not contain[DONE]. - Unmapped models pass through unchanged — this is tested explicitly in the smoke test.
If you add a new field that needs rewriting, extend the field list in rewriteModelFields. If you change to real JSON parsing, be aware SSE responses are JSON-per-line and the smoke test relies on the current contract.
Windows-Specific Code
tray.gocallsuser32.dllMessageBoxWdirectly viasyscall/unsafefor native dialogs (test connection, errors). Do not port this to other platforms without replacing it.- Icons are embedded at compile time (
//go:embed assets/icon.ico,assets/icon-error.ico). High-resolution versions (256x256 max) are generated fromscripts/gen_icons.py. config.gokeys off%APPDATA%(falls back to~/.configonly if unset — effectively Windows-only behavior).tunnel.godownloads and cachescloudflared.exein%APPDATA%\Z-API-Proxy\. The download URL picksamd64orarm64based onruntime.GOARCH.- Tunnel preference persisted in
%APPDATA%\Z-API-Proxy\tunnel.pref(1/0, default off). When enabled, auto-starts silently on launch viaautoStartTunnel(no popup dialog). updater.godownloads MSIs from GitHub releases and launchesmsiexec /i <path>— the WiXMajorUpgradeelement handles replacing the old version.- Clipboard operations use
powershell Set-Clipboardviaexec.Command. - Contact Developer uses
rundll32 url.dll,FileProtocolHandler mailto:...to open the default mail client.
Security Hardening Details
A few security properties are easy to accidentally regress if you're not aware of them:
config.Security.VerifyKeyis force-enabled.Load()unconditionally setscfg.Security.VerifyKey = trueregardless of what's inconfig.toml— a plain TOML bool can't distinguish "absent" from "explicitly false", so there was no way to flip the default for new installs without also re-enabling it for existing installs that hadverify_key = falseon disk. Enforcement itself still only activates oncecfg.Upstream.APIKeyis also set (see the&&gate inproxy.ServeHTTP), so this is a no-op for anyone without an upstream key configured — but it IS a behavior change for anyone who had both an API key set andverify_key = false. There is currently no supported way to run with an upstream key configured and verification off; if that's ever needed, it requires a real tri-state (e.g.*bool) instead of a plain bool, not just flipping this line back.worker.js'smatchKeydoes a constant-time comparison (timingSafeEqual, XOR-accumulate over the full fixed-length key, no early exit) of the key portion, mirroringproxy.go's use ofcrypto/subtle.ConstantTimeCompare. Only the length check and thekey + '_' + clientIdsuffix parsing are ordinary (fast) string ops — length and clientId aren't secret. If you touch this function, preserve the "constant-time over content, fast-reject on length" split; don't reintroduce===/startsWithon the key portion itself.worker.go'sTEST_KEYis generated per-installation, not a shared hardcoded constant.loadOrCreateTestKey()generates 24 random bytes viacrypto/randon firstDeploy()and caches them at%APPDATA%\Z-API-Proxy\worker-testkey.pref(0600);TestDeployedWorkerreads the same cached value. A Worker deployed before this existed (or from a different machine) will fail/testuntil the nextDeploy()pushes a freshTEST_KEYsecret — this is expected, not a bug.cursor.go'swriteSettingsJSON(and the equivalentcmd/cursor-setup-helper/main.goapplySettings) write Cursor'ssettings.jsonwith 0600, not 0644 — it contains a composite API key (cursorKey_clientID) in plaintext.state.vscdb(Cursor's own SQLite state file) is intentionally left alone; it has its own lifecycle outside this app's control.- Download integrity:
tunnel.go'sensureDownloaded()verifiescloudflared.exe's SHA-256 against the digest GitHub publishes for that release asset (GET /repos/cloudflare/cloudflared/releases/latest, theassets[].digestfield — computed by GitHub at upload time, not scraped from release-note text) before treating it as trusted.updater.go'sDownloadAndInstall()does the same for MSIs againstchecksums.txt(see Commands section above). Both are best-effort and fail open: an unreachable API or a missing digest/manifest logs a warning and proceeds unverified rather than breaking the feature; a mismatch against a digest/manifest that IS present is always a hard failure. This defends against corruption or tampering in transit — it does NOT defend against a compromised upstream repo/release process (that needs code signing, out of scope here).
Logging
Logs go to %APPDATA%\Z-API-Proxy\proxy.log (append mode), not stdout — there is no console because of -H windowsgui. When debugging a deployed build, read that file. A failed log-file open is silently ignored (logging falls back to stderr, which is invisible in a GUI app).
Conventions
- Package layout: all logic under
internal/, one package per concern (config,proxy,counter,tray,tunnel,updater).main.gois wiring only. go.modmodule path isz-api-proxy(no domain prefix); internal imports usez-api-proxy/internal/....- Dependencies:
pelletier/go-toml/v2(config),getlantern/systray(tray),golang.org/x/sys(registry). Avoid adding dependencies unless necessary. - Style: short receiver names (
p *Proxy,m *Manager,t *trayApp,c *Counter), exported constructors namedNew. - Version is managed via the
VERSIONfile and injected at build time with-X main.version=.... - Release artifacts use
winin their names (e.g.z-api-proxy-win-1.0.0-alpha-amd64.msi). - Tests:
go test ./...runs unit tests inconfig,tray,tunnel,updater, andworker(the Go-side, logic-only re-implementation tests). The Pythontest_smoke.pyis a separate integration test.internal/worker/worker.test.mjsis a separate Node.js test that executes the realworker.js(see Commands) — run it whenever you touchworker.js; the Go tests next to it do not exercise actual JS runtime semantics. Tests that touchconfig.AppConfigDir()(e.g.worker's test-key persistence tests) sandbox it witht.Setenv("APPDATA", t.TempDir())— do the same for any new test that writes app state, don't let tests touch the real user's%APPDATA%\Z-API-Proxy\. - Tray menu handlers that open a window or do anything non-instant MUST go through the
guarded(mu, name, fn)helper intray.go(selectloop inhandleMenu). Every trayClickedChcase is aselect/defaultnon-blocking send fromgetlantern/systray— a user re-clicking a menu item while the first click's dialog is still being built fires a second event that races the firstgo func(){}()and opens a second native window. Each handler needs its own dedicatedsync.Mutex(seedeployMutex,registerMutex,configMutex, etc.) — do not skip this for "fast" actions; slow-feeling ones (dialog construction, network calls) are exactly the ones users double-click.