Skip to content

Migrate GobboNet API to Go for cross-platform support - #2

Open
jmccardle wants to merge 32 commits into
ElodineOfficial:mainfrom
jmccardle:go_go_gobbonet
Open

Migrate GobboNet API to Go for cross-platform support#2
jmccardle wants to merge 32 commits into
ElodineOfficial:mainfrom
jmccardle:go_go_gobbonet

Conversation

@jmccardle

Copy link
Copy Markdown
Contributor

gobbonet is a single distributable binary that replaces the API logic in fileserver.ps1 and launch.bat. With the GobboNet API itself implemented in Go, we continue using PowerShell for Windows setup functions, while the API itself makes the GobboNet front-end available on Linux and Mac.

This branch also includes my attempt at an NSIS installer which uses the powershell scripts as steps in the install wizard.

Modifications to Front-End Facing Code

chat.html, js/, css/, launch.bat, fileserver.ps1 are unmodified. Linux, Windows, and Mac get the same API.

hardware-probe.ps1 is given a new optional parameter, -IniPath, because NSIS has no JSON parser.

The probe's real output is hardware.json, and launch.bat reads it via -EmitEnv, which writes bare KEY=VALUE lines that batch consumes with a for /f loop. The installer can use neither: NSIS's ReadINIStr wraps Win32 GetPrivateProfileString, which needs a [section] header — bare KEY=VALUE lines with no header return nothing. So -IniPath writes a flat [hardware] INI with the five keys the wizard actually reads (installer/gobbonet.nsi:420-424):

gpu_name, vram_gb, ram_gb, disk_free_gb, recommended_tier

The new argument makes hardware-probe.ps1 something that can be used in the installer: the wizard can suggest models at setup, and the selected models can be downloaded with a progress bar and no additional setup. My goal was a "start GobboNet" checkbox at the end of the installer, which already has llama.cpp and at least one model ready to run.

GO_MIGRATION_INVENTORY.md records every behaviour carried over from fileserver.ps1, including the 1.4-to-1.5 re-porting changes

Ideas worth borrowing even if my PR can't be merged

  • One file to distribute. CGO_ENABLED=0, -trimpath; build-release.sh cross-compiles linux/amd64, linux/arm64, windows/amd64, darwin/arm64 and darwin/amd64 and bundles each with web/ and a SHA256SUMS.
  • Bug reports that name a build. The version is stamped at link time and reported from gobbonet version, the startup banner, and /health-fileserver. A user or tester can copy a build identity out of a browser without even using command prompt. The git commit would show if they're on a prerelease or release build.
  • The installer does the first-run work. Hardware probe, model recommendation and model download are wizard pages instead of console prompts. Avoids terminal pop-ups
  • llama.cpp is bundled rather than downloaded. Downloading another executable looks like malware (launch.bat already notes this). The .gguf download stays online (inert data, and too large to bundle) and the Go version borrows launch.bat's integrity policy exactly: hash mismatch fatal, LFS-pointer detection, sub-1GB backstop.
  • Config and data are separated (~/.config/gobbonet, ~/.local/share/gobbonet), with the Windows layout — config.toml next to launch.bat — still last in the discovery order, so a portable install works unchanged.

Known gaps

  • Not yet run on Windows. Needs installer testing on various hardware
  • No CUDA llama-cpp not bundled. ~300 Megs would dominate the size of GobboNet, so we might revisit bundling the CPU version of llama and figure something else out

Overview for review

Area Files
Wire compatibility with the v1.5 frontend internal/server/conformance_test.go, internal/jobs/
Process supervision (llama-server lifecycle) internal/supervisor/
Config discovery and layout internal/config/, GO_CONFIG_SPLIT.md
Installer installer/gobbonet.nsi, installer/gen-catalog.py, installer/README.md
Overall design and decision log GO_SERVER.md, GO_MIGRATION_INVENTORY.md

jmccardle and others added 12 commits August 9, 2026 23:20
tested these setups:
* config discovery
* remote mode
* local mode (llama-cpp process supervision)
* example GGUF files
* legacy auth migration
* portable layout
* upstream API keys

Every fix below is something that exercise found and
the unit tests had missed.

supervisor: do not leak llama-server's children

  terminateGroup derived the group id with Getpgid(child.Pid), which
  fails with ESRCH once the reaper has Wait()ed the child -- while the
  rest of the group is still running -- and then signalled the dead PID.
  stop() compounded it by returning early whenever the leader had already
  exited, which is exactly the crash and rollback path. An orphan is
  reparented to init, so it disappears from any walk of our descendants
  while still holding GPU memory; the next model then fails to allocate a
  backend buffer with nothing pointing at the cause.

  Capture the group id at launch and sweep by id, confirming the group is
  empty with kill(-pgid, 0) rather than trusting the leader's exit.

models: honour the real context length under the hard overrides

  The five filename overrides (llama3, mistral-small, mistral-nemo,
  granite, command-r) returned before MaxCtx was assigned, so the most
  common models all reported the 131072 placeholder. Against a server
  launched with --ctx-size 8192 the UI offered a window llama.cpp
  rejects on every request past the real limit.

models: exclude multimodal projectors from the model list

  Vision models ship mmproj-*.gguf beside the weights. llama-server takes
  a projector via --mmproj; handed one as --model it refuses to load, so
  listing it offers a choice that can only fail and looks like a broken
  swap.

models: normalise family to the vocabulary chat.html indexes

  family is looked up in the stop-string table by exact match, so
  architecture-derived values like qwen2 and phi3 missed every time and
  the model's turn markers were rendered to the user as content.

config: keep GGUFs out of ~/.config

  defaults() and the generated TOML both pinned model_dir to "./models",
  which resolves against the config directory -- the XDG violation
  GO_CONFIG_SPLIT.md calls out. Default to <data_dir>/models; a portable
  install opts back in with a relative path.

config: stop `config set` eating its own documentation

  The uncomment pattern allowed leading whitespace and replaced the first
  match, so setting a key whose only occurrence was an indented prose
  example rewrote the explanation instead of adding a setting.

proxy: put our CORS headers on proxied responses

  ModifyResponse stripped llama.cpp's copies and added none, so /llm,
  /search and /embed answered with no CORS at all while the preflight for
  those same paths answered "*". A browser told the preflight passed and
  then handed a response with no Allow-Origin blocks the result.

Three changes for consistency rather than because a test failed:

  - config get/set accept --config like every other subcommand. Without
    it, `config set` edited a different file from the one the next
    `serve --config` reads.
  - A missing llm_api_key_file is fatal to serve and check but no longer
    blocks config get, matching where a missing server_exe is reported.
  - The local degraded path guesses architecture from the filename the
    same way the remote path already did.

Adds 15 tests pinning each of these.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>.
Testers need to run this without a toolchain, and a bug report is only
actionable if it names the build that produced it.

internal/version carries an identity stamped in at link time. It defaults
to "dev" rather than a plausible-looking number, so an unstamped build can
never be mistaken for a distributed one. Three places report it, because a
tester may have any one of them to hand: `gobbonet version`, the startup
banner, and /health-fileserver -- the last so a build identity can be
copied out of a browser with no terminal open.

Full() also reports the revision the Go toolchain embeds on its own from
the real repository state. That is a cross-check rather than a source of
truth: disagreement with the ldflags value means the stamp is stale. It
carries the toolchain and platform too, which is the first thing to look
at when one tester behaves differently from the rest.

build-release.sh cross-compiles linux/amd64, linux/arm64, windows/amd64,
darwin/arm64 and darwin/amd64, and bundles each binary with web/ and the
docs into an archive under dist/ with a SHA256SUMS. CGO_ENABLED=0 and
-trimpath, so each one is static and unpacks ready to run.

The script refuses to build from a dirty tree. A sha that does not
describe the code inside the binary is worse than no sha at all: the bug
gets filed against a commit that does not contain it and cannot be
reproduced. --allow-dirty overrides and marks the version -dirty, so it
stays visible everywhere the version is reported.

It bundles web/chat.html and deliberately not the repo-root chat.html,
which is still the older Windows-lineage copy without the /llm/jobs
client. Both the script and GO_SERVER.md say so, so the fork cannot be
shipped by accident.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Collapses the "installed -> running" dialog sequence into a single
finish-page checkbox. The hardware probe, model recommendation and
model download that launch.bat asked for at a C:\> prompt are now
wizard pages, and gobbonet.exe starts llama-server itself, so the
checkbox is one Exec with no console window in between.

llama.cpp is bundled rather than downloaded. launch.bat documents that
"cmd -> temp .ps1 with Bypass -> downloads an executable archive" is
what behavioral AV reads as malware staging, and an unsigned installer
fetching a zip of .exe files is the same shape with a worse parent
process. A .gguf is inert data, so the model download stays online --
it is also the only file too large to bundle.

The model catalogue is generated, not copied. launch.bat holds it in
three places (menu echo lines, the inline PowerShell $min table and
recommendation ladder, and the MODEL_CHOICE download blocks);
gen-catalog.py parses all three into models.ini, which NSIS reads with
native ReadINIStr. build-installer.sh regenerates it every build so it
cannot lag launch.bat.

Download integrity mirrors launch.bat's policy exactly: HuggingFace
serves an LFS pointer instead of the model on failure, and it arrives
as a clean HTTP 200. Hash mismatch is fatal, an unparseable pointer
warns, and a sub-1GB file is fatal as the backstop.

hardware-probe.ps1 gains an optional -IniPath that writes a flat
[hardware] INI alongside hardware.json, so NSIS can read the probe
without a JSON parser. Additive: hardware.json is written either way
and existing callers are unaffected.

The launch.exe / launchLAN.exe C shims are dropped. They existed to
locate the install folder and ShellExecute a .bat; gobbonet.exe is a
real executable, so the shortcuts point straight at it.

Compiles clean under NSIS 3.08 (Elodine used 3.09). Not yet run on
Windows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A directory named vendor/ at a Go module root is reserved by the
toolchain: `go build` refused with "not marked as explicit in
vendor/modules.txt" for every dependency once the llama.cpp bundle
landed there. It now lives at installer/vendor/llama-cpp, next to the
build script that consumes it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
v1.5 split the frontend from a single chat.html into chat.html plus 24 js/
and 15 css/ files. The fork carried its own copy of the frontend under web/,
which worked while that was one file and stopped working at 39: a copy goes
stale the moment upstream touches any of them, the server keeps serving the
stale copy, and nothing reports a problem.

web/ is now assembled by stage-web.sh from the repo-root frontend and is no
longer committed. The root stays the single source of truth, so an upstream
merge lands in one place. stage-web.sh refuses to build a web root whose file
count disagrees with what chat.html actually references -- a forgotten <script>
tag fails the build instead of producing a blank page with console errors. A
checkout that has never run it still works: detectWebRoot() falls through to
the working directory and serves the root directly.

Also collapses the two release version literals into a VERSION file. They had
drifted to 1.3 in build-release.sh and 1.4 in build-installer.sh -- under a
comment claiming the latter "matches build-release.sh's scheme" -- while the
tree carried upstream's 1.5.1 frontend, so a tester's report would have named
a build that did not describe what they were running. VERSION_QUAD is padded
to four components rather than suffixed ".0.0", which on a three-part version
produced "1.5.1.0.0" and made makensis abort.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjDjeRg7H6bUEuhZLVMsnk
The /llm/jobs wire contract belongs to fileserver.ps1, which defined it. This
port had changed the poll payload to send raw UTF-8 in a `chunk` string,
saving the 33% base64 overhead.

That saving is real and irrelevant. js/03-generation.js reads chunk_b64 and no
other key, and it does not error on an unrecognised payload: it finds no
chunk_b64, leaves `offset` where it was, computes drained = offset >= size as
false, and polls a perfectly healthy job forever. The reply never appears, the
user watches a spinner, and nothing is logged on either side. Verified against
the stock v1.5 frontend before changing it back.

The rune-alignment logic in read() goes with it. It existed only because Go's
JSON encoder turns a split UTF-8 character into U+FFFD; base64 has no such
hazard, and the client already decodes with TextDecoder(..., {stream: true}),
which rejoins characters split across chunks. It also carried a stall of its
own: a window containing no complete character returned empty and left `next`
at `from`, so bytes that were not UTF-8 at all -- which base64 carries
perfectly -- would freeze the offset forever while the job streamed on. read()
now returns a plain byte range, exactly as the PowerShell poll branch does.

conformance_test.go pins the field name, because the failure mode is a silent
hang rather than a test failure. The jobs tests now assert the property that
replaced alignment: read() must always advance `next`, whatever the bytes are.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjDjeRg7H6bUEuhZLVMsnk
v1.5 replaced hardware-probe.ps1 wholesale: 552 lines and 13 functions became
1,961 and 40, first-hit-wins GPU detection became multi-source evidence
merging with credibility scoring, and the parameter block changed. Its header
documents four real bugs the rewrite fixes, including the floor-division that
under-reported every GPU by one tier and the dxdiag parse that reported ~1.6
million GB of VRAM. Worth taking.

It has no -IniPath, which the NSIS wizard calls. -EmitEnv does not substitute:
it writes bare KEY=VALUE lines and ReadINIStr needs a [section] header. So
this is a port onto a different file rather than a re-apply of the old 42-line
block -- new payload shape, new helper conventions.

The sanitiser is separate from ConvertTo-EnvSafe on purpose. ! and % are
delayed-expansion hazards in batch and must be stripped there, but they are
ordinary characters to the INI API and stripping them mangles real GPU names.
What breaks an INI read is a newline, a ';', and leading/trailing space.
Ordering matters and is the one subtle part: the substitution must run BEFORE
ConvertTo-SafeAscii, which deletes everything outside \x20-\x7E including CR
and LF -- so running it first welds the words either side of a line break
together ("Radeon RX\r\n7800 XT" -> "Radeon RX7800 XT"). Caught by the
self-test assertion written for it.

A failed INI write exits 1, unlike -EmitEnv's warn-and-continue. launch.bat
can fall back to parsing hardware.json; the installer cannot, which is the
whole reason the file exists. And its failure is not a visible error but a
wrong recommendation: with vram_gb unset the wizard reads 0, matches no rung
on the VRAM ladder, falls through to the catalogue default and offers a model
the machine cannot load. Exiting non-zero puts it on its "could not read this
machine's hardware" path, which offers the catalogue unfiltered and says so.

Three sanitiser cases are covered by -SelfTest, which needs no hardware and no
installer. Not yet run on Windows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjDjeRg7H6bUEuhZLVMsnk
RunProbe invoked $INSTDIR\hardware-probe.ps1 from the probe page, which sits
at Page custom ProbePageCreate -- three pages before MUI_PAGE_INSTFILES, where
Section SecMain writes that file. Nothing had been written to $INSTDIR yet, so
the probe could never run: nsExec returned non-zero and every install silently
took the "could not read this machine's hardware" branch, offering the
catalogue unfiltered. It looks like a normal outcome on a machine with an
unrecognised GPU, which is why it would have shipped.

.onInit now extracts the probe into $PLUGINSDIR alongside models.ini, and the
page runs that copy with its outputs there too. SecMain still installs its own
copy for launch.bat, and copies hardware.json into $INSTDIR so the first
launch inherits the probe rather than re-running it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjDjeRg7H6bUEuhZLVMsnk
INDEX.md was a structural index of chat.html by line number -- renderMessages()
at 7848 and so on. Every one of those died with the split. It is not replaced
with new line numbers: each extracted module records the range it came from in
its own header, so the file is now a translation table from any stale line
reference into the module that holds it, plus the load-order hazards. For
finding a symbol, grep across js/ is faster and always correct.

AGENTS.md described an 11,402-line single-file frontend in three places.
GO_SERVER.md claimed web/chat.html was the bundled fork and the root copy the
older lineage -- exactly backwards now -- and documented the `chunk` framing
and the rune alignment that went with it. GO_MIGRATION_INVENTORY.md recorded
"Drop base64 framing -- Adopt" as a decision; it is marked REVERSED with what
it cost, since the reasoning was sound and the conclusion still wrong.

Also documents the platform gap plainly in GO_SERVER.md: the server is fully
cross-platform, but hardware detection and guided model download are
PowerShell and batch, so Linux and macOS have no first-run path. States the
three ways out and that current state is "leave it".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjDjeRg7H6bUEuhZLVMsnk
jmccardle and others added 7 commits August 20, 2026 11:42
Upstream shipped 1.5.4 (error 500 fix) and 1.5.8 (power-user QOL) while
this branch was out for review. The frontend half merges untouched --
this branch has never edited chat.html, js/, css/ or the launcher
scripts, so all 2,116 lines of upstream's changes arrive as-is and
stage-web.sh picks them up.

The server half does not merge, because on this branch the server is Go.
Three behaviours landed in fileserver.ps1 that gobbonet has to grow
separately, and they follow in their own commits:

  - GET/POST /perf, which the new settings panel in js/02-model.js calls
  - the 8080 -> 9066 and 11434 -> 11437 port moves
  - job supersede-instead-of-429

Only .gitignore conflicted: upstream added .gobbonet-port next to the
entries this branch had already restructured.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two port defaults moved in 1.5.8, both to stop squatting where the
neighbours already live.

llm_url 11434 -> 11437. 11434 is Ollama's default. On a machine with
Ollama installed the launcher found something answering there, concluded
llama-server was already up, skipped starting its own, then found
nothing healthy and restarted -- a loop caused entirely by the shared
port.

listen_port 8080 -> 9066 ("gobb" on a keypad). 8080 is the single most
contended port on a developer machine, and on Windows the dynamic ranges
Hyper-V, WSL2 and Docker Desktop reserve can swallow it, which presents
as a bind failure netstat cannot account for.

Existing installs do not move: config.toml is written with explicit
values on first run, so only a fresh install sees the new defaults.

Not adopted: upstream's .gobbonet-port file and its silent clamp to
1024-32767. The port is already a config.toml key that the installer
writes and `gobbonet config set listen_port` edits, and a second file
saying the same thing is a second thing to disagree. The clamp is worse
than the error it replaces -- a config that asks for a port and quietly
gets a different one is the failure this branch keeps removing. Out of
range still errors; the reasoning behind 32767 is in the generated
file's comments where someone choosing a port will read it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Upstream 1.5.8 gave context size, GPU layers and KV cache type a UI.
js/02-model.js GETs /perf to fill the panel, POSTs to save, and then
posts the already-loaded model to /swap-model to apply -- deliberately
reusing the hot-swap path so there is one restart mechanism with one
lock and one status feed. Without this endpoint that panel is dead UI on
the Go server, so the wire shape here is upstream's verbatim, camelCase
and all, because one frontend file drives both servers.

The storage is not upstream's. fileserver.ps1 takes its baseline from
GEMMA_* environment variables that launch.bat recomputes from hardware
every run, and overrides them with .gobbonet-perf.json. Here config.toml
IS the baseline -- installer/gobbonet.nsi writes ctx_size and
kv_cache_type into it from hardware.ini, and off Windows a human writes
them by hand -- so the override goes in a perf.toml beside it and reset
deletes that file.

Writing into config.toml instead would have been simpler and wrong: the
first save destroys the probe's numbers, after which "reset to auto" can
only mean the compiled-in 16384/99/q8_0. That is precisely backwards on
the machines that need reset most. A 6GB card probed down to ctx 8192
would be reset UP to 16384 and stop loading, with the button that broke
it labelled "put it back how it was".

Two deliberate divergences beyond storage:

A perf.toml that is unparseable or out of range stops the server with a
message naming the file and the value, where upstream warns and silently
falls back to auto. This file is only ever written by code that
validates first, so a bad one means a hand edit -- and running settings
the user did not choose, having noticed they did not choose them, is the
kind of quiet substitution this port keeps removing.

Load() does not apply the overlay; only the serve path does. `config get
ctx_size` therefore keeps reporting the file that `config set ctx_size`
writes. A getter and a setter that disagree about which file they mean
would be worse than either layer on its own.

Supervisor tuning moves out of the fixed Options into a mutex-guarded
Tuning that SetTuning replaces and BuildArgs reads once per launch, so a
save landing mid-assembly cannot pair one model's context size with
another's cache type. /active-model.json now reports the live context
rather than the startup one, or the client would keep budgeting prompts
against a window llama-server is no longer running.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Past the concurrency cap the jobs manager answered 429. Both halves of
that were wrong, and upstream fixed both in 1.5.8.

llama-server is launched with one slot, so a cap of four only bought a
queue it could not serve. The reported symptom was the giveaway: press
Stop, send again, and the new generation sat behind a request that was
still running because llama-server had not noticed the disconnect yet.
Do it a few more times and four stacked generations fought over one slot
until they all drained. And a 429 to someone who just pressed Send is a
refusal, when what they plainly want is the new generation and not the
old one.

So: cancel the oldest live work, wait for it to actually let go, then
dispatch. The wait is the part that matters -- llama.cpp frees its slot
when it notices the disconnect, and dispatching before that happens
queues the new request behind a generation nobody is reading, which is
precisely the stall this removes. Job.done is closed by the worker after
the response body is closed, so waiting on it means the socket is gone
rather than merely the context being cancelled.

Three differences from upstream's version:

The wait is 5s, not 2.5s. Upstream picked 2.5s because PowerShell's
accept loop is single-threaded and the whole server stalls for the
duration -- it is a budget for collateral damage, not for the teardown.
Nothing else blocks here, so the bound comes from what is being waited
on. Reaching it now means something is genuinely wrong.

Only enough work is shed to get under the cap, oldest first, rather than
every live worker unconditionally. At the default cap of 1 that is
identical; above it, shedding everything would throw away generations
that had room to run. Ordering is a monotonic seq, because startedAt is
unix seconds and two jobs in the same second would sort arbitrarily.

Supersede happens after the body is validated, not before. Upstream
sheds first and parses second, which lets a malformed request from a
buggy client kill a generation the user is reading.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
GO_SERVER.md gains the two sections the new code needs: Runtime tuning
(what /perf is, why the override is a perf.toml beside config.toml
rather than a rewrite of it, and why a bad one stops the server where
upstream's warns), and the supersede half of Generation jobs. Ports get
their reasoning recorded next to the discovery order, including what
this deliberately does not adopt -- .gobbonet-port and the silent clamp.

Also finishes the VERSION-file note left over from the last commit:
version.go and the Releases section still described a hardcoded "1.3-go-"
stamp that cc715ef replaced with the VERSION file.

AGENTS.md documents the whole project, PowerShell included, so its
diagram and security table carry the new port numbers with a note on why
they moved. GO_MIGRATION_INVENTORY.md is a record of a comparison made
at a point in time, so /perf is noted as arriving after it rather than
retrofitted into a table of two implementations that never had it.

The line counts in AGENTS.md and INDEX.md were stale the moment the
merge landed -- fileserver.ps1 1,169 -> 1,967, launch.bat 1,687 ->
2,257, chat.html 906 -> 950, and eleven js/ modules. INDEX.md already
says this is exactly the failure mode of a hand-maintained structural
index; refreshing them keeps that argument honest until something
generates them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two saves landing together could interleave between reading the current
tuning and publishing the new one, so perf.toml and the running server
ended up describing different settings. The file is the half that
survives a restart, which means the disagreement outlives the request
that caused it -- the panel shows one context size, the next boot uses
another, and nothing reports either.

A separate write mutex rather than holding the RWMutex across the whole
POST, so a GET is never blocked behind a disk write.

The test drives eight concurrent saves and compares what the server
reports against what a fresh ApplyPerf reads back off disk. It fails
reliably on the unserialised version.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
hardware-probe.ps1 is ~2,000 lines, not ~1,400 -- the figure was right
when the platform-coverage section was written and upstream has grown
the file since. It is load-bearing there: it is the argument for why
porting hardware detection to Go is its own piece of work.

stage-web.sh copies 40 files, not 39. 24 js + 15 css is 39, and
chat.html makes forty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jmccardle

Copy link
Copy Markdown
Contributor Author

Updated to 1.5.8

The frontend half merged untouched. This branch has never edited chat.html, js/, css/, fileserver.ps1, launch.bat or setup-lan.bat, came straight through. the Go server just serves them as static files and adds nothing. Only .gitignore conflicted, where you'd added .gobbonet-port next to entries I'd already restructured.

The server half doesn't merge, because on this branch the server is Go. Three things you shipped in fileserver.ps1 needed porting separately:

/perf

the big one. js/02-model.js GETs /perf to fill the panel, POSTs to save, then posts the already-loaded model to /swap-model to apply. Without the endpoint that whole settings panel is dead UI on the Go server, so the protocol is identical. current / auto / overridden / modelMaxCtx, and {reset:true} on the way in. One frontend file has to drive both servers, so the JSON isn't mine to redesign.

Port moves

8080 to 9066 and 11434 to 11437. Cooperates with Ollama

Supersede instead of 429

cancel the running generation, wait for it to let go, then dispatch. Your comment about the old value describing the bug rather than defending it was the useful part; I ported the reasoning, not just the number.

design changes

Perf overrides live in a perf.toml beside config.toml, not a .gobbonet-perf.json. Same idea, slightly different choices for the other platforms

Supersede happens after the request body is validated, not before. Handle-Jobs sheds live workers and then reads the body, so a client that sends malformed JSON kills a generation the user is actively reading. Small, but it's free to fix in either language.

No .gobbonet-port and no clamp to 1024–32767 on the Go side. installer sets the port in config

not ported: the fileserver.log startup log. Linux/Mac don't have the "hidden powershell console" issues that necessitate it

have a look around

Go is not my language of choice, but "single binary, everything included" makes it match the GobboNet philosophy.

dependencies

  • BurntSushi/toml for config
  • gpustack/gguf-parser-go for reading GGUF headers
  • golang.org/x/crypto/argon2 for password hashing

Everything else is stdlib.

cmd/gobbonet/          504   CLI entry: serve, check, config get/set/keys, set-password, version
internal/config/      1104   config.toml, discovery, mode detection, the perf.toml overlay
internal/supervisor/  1068   llama-server lifecycle: launch args, hot-swap, health, rollback
internal/models/       994   GGUF header parsing, the family/template classifier
internal/jobs/         640   detached generation (/llm/jobs*), in memory
internal/server/       626   routing, auth gate, /health-fileserver, /perf
internal/auth/         431   sessions, Argon2id + legacy SHA-256 migration, login rate limit
internal/proxy/        207   streaming reverse proxy to llama.cpp / search / embed
internal/state/        153   /state and /state/info
internal/httpx/        112   response helpers, CORS, MIME — the Write-Json equivalents
internal/static/        87   static file serving
internal/version/       80   build stamping

jmccardle and others added 3 commits August 20, 2026 12:24
css/01-tokens.css tells the user to drop atkinson-hyperlegible.woff2 into
fonts/ themselves -- the font is not in the repo. On the PowerShell server
that works, because it serves the repo root. Here it did not: stage-web.sh
copies only chat.html, js/ and css/, so a font the user had correctly
installed never reached web/ and 404d forever, with no symptom beyond a
face that silently never applied.

Absent stays a supported state (font-display: swap falls through to the
monospace stack, which is why upstream can ship without the file), so
fonts/ is staged when present rather than required.

Also corrects the '39 files' count in two comments -- the frontend has been
40 files since 1.5.8.
fileserver.ps1 spools each generation to <root>/.jobs/. The .sse file holds
the model's reply transcript, so a stray 'git add -A' can commit chat output.
.gitignore already covers conversations/, .gobbonet-state.json and the other
runtime files; this one was missed.

SECURITY.md notes that uninstalling removes the job spool — this keeps it out
of version control in the meantime.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 427f88e)
Follow-up to the cherry-picked ignore, not a correction of it: the line is
right and needed. But this branch replaces fileserver.ps1's runtime half, and
internal/jobs/ holds spools in memory rather than on disk, so a reader here
would otherwise find an ignore for a directory nothing in the Go tree ever
creates and reasonably assume it went stale.

The ignore stays regardless of what happens to this PR -- fileserver.ps1 is
still the server on main.
@jmccardle

Copy link
Copy Markdown
Contributor Author
60c7a0a  John McCardle   Note which server writes .jobs/
2d54c76  James Sesler    Ignore the .jobs/ generation spool
                         (cherry picked from commit 427f88e5ef3b…)
b7b583d  John McCardle   Stage fonts/ into the web root when it exists

I incorporated changes from @TheAmericanMaker 's commentary on #16

@jmwielandt

Copy link
Copy Markdown

y'all are amazing <3

@jmccardle

Copy link
Copy Markdown
Contributor Author

Keeping the Go port in step with the rest of the queue

reviewing our user's bug reports and PRs, searching for findings that would also apply to the Go port.

The port barely touches your files

git diff --diff-filter=M origin/main...go_go_gobbonet — the Go port modifies
exactly two files this repo already owned:

file change
.gitignore +41 / −1
hardware-probe.ps1 +100 / −1

Because the Go port is almost entirely 100% additive (just copying the GobboNet API into a language that will run outside of Windows), there's not any conflict with upstream.

Every open PR, merged into both bases

Throwaway worktree, git merge --no-commit --no-ff against each base, aborted
after recording the result:

PR into main into go_go_gobbonet whose cost
#3 gguf-metadata-injection clean clean
#4 xss-render-escaping conflict js/13-dashboard.js same yours
#5 untrusted-state-code-exec clean clean
#6 login-brute-force clean clean
#7 urlacl-and-firewall-scope conflict setup-lan.bat same yours
#8 verify-engine-download clean clean
#11 keyless-search-provider-seam conflict launch.bat same yours
#15 orphaned-process-cleanup clean conflict .gitignore mine
#16 accuracy-fixes clean conflict .gitignore mine

Across nine open PRs, the port's total merge cost is one trivial .gitignore
resolution, twice.

A clean merge is not a safe merge

example: #6 merges into today's main without conflict and it bricks first-run setup.

1.5.8 (fcf755b) added a second, every-run secret validator. In the merged tree
it lands at launch.bat:257, still matching ^hex:hex$ only, directly after
call :setup_password. So a fresh install writes the new PBKDF2 secret, then
exits with ".gobbonet-secret is malformed", permanently.

Verified with git merge-tree --write-tree origin/main pr-6. The fix is one
line, using the same alternation the PR already wrote at line 534.

Six of the eight PRs are based at 5524fd4, before 1.5.8 rewrote large parts of
launch.bat and fileserver.ps1. Every one of them wants a merge-semantics
check, not just a conflict check.

Review of open PRs

#6 -- one line. As above. Design is otherwise sound and minimal.

#5 -- misses a third door. importData(fileInput, 'cards') at
js/21-data.js:95-102 merges characterCards verbatim with
customCodeEnabled: true intact, and activateCard() runs it on the next
click. That path is 55 lines above the hunk the PR does add, in the same
function, and this repo's own exportData('cards') emits exactly that file
shape. Two-line fix. Also, the typeof neutralizeUntrustedCode === 'function'
guard at both call sites doesn't work, and its fallback
silently skips the security control.

#4 -- one line to revert. escapeHtml(icon) at js/13-dashboard.js:78
double-escapes the icons in default-characters.json; all four are already HTML
entities, so every preset card renders literal &#128126; on a fresh install.

#4 and #5 go together #5 stops customCodeEnabled arriving
true through /state; #4 stops injected script setting it back.

#3 -- one line, and it is not the sanitiser itself. What is wrong is that ConvertTo-CmdArgSafe is also applied
to filesystem paths (fileserver.ps1:1105, identify-model.ps1:497), where
&, % and ^ are legal. The path is validated at :1050 and a different
string is emitted at :1105, so an install path under C:\AI & ML\ parses incorrectly.

#7 -- stale, recommend closure. 1.5.8 already rewrote the URL-ACL block,
and this PR's rewrite reintroduces the locale bug that rewrite removed.

Change (2) also doesn't do what it says: set rule name=X new enable=yes matches by
name, so all three profile copies including Public still get enabled. And
:ensure_urlacl deletes before adding with no rollback and no errorlevel
check, so a failed re-scope leaves the machine with no reservation while
printing [OK].

Change (3), dropping the LAN rules for the loopback-only
services, is sound on its own and rebases cleanly, because the deletes are by
rule name.

#8 -- accept. I verified both digests independently against the publishing
hosts, two ways each. They're correct, and LLAMA_PIN_TAG is still b9294
after merging.

Separate note: :prompt_yn (lines 305-312) never clears _YN
before set /p, so under launch.bat < nul the new unverified-download prompt
inherits Y from the preceding one and self-approves. Pre-existing, but more
important with PR #8 . set "_YN=" fixes it.

#11 -- the headline claim doesn't hold. The frontend still hard-gates on an
Ollama key, so setting SEARCH_URL doesn't actually get you keyless search end
to end. Two things I checked because they seemed worth checking, both clean: the
base64 blob does decode to a faithful ancestor of searchproxy.ps1 and aither-adk
appears nowhere in the diff; the promotion is confined to the PR description.

#15 -- one line. launch.bat:9-25 swaps cmd /k for cmd /c and drops the
documented "this window can NEVER silently vanish" guard; deliberate failures
still hit :fatal's pause, an unhandled one now closes the window and takes the
error with it. if errorlevel 1 pause after the inner cmd /c returns. The
rest holds up under scrutiny — the PID-reuse guard has a real CreationDate
tiebreak, all four services skip ownership on their reuse branches so a
pre-existing Ollama is never claimed, and the hot-swap marks ownership before
Start-Process so a mid-swap crash over-claims in the safe direction. I went
looking for a smaller Job-Object answer and concluded there isn't one in batch:
a job handle needs a live holder and cmd.exe can't be one, so it would just be
the watchdog again. 207 lines is right-sized for the language.

Independent of any PR: the base64 search-proxy payload at launch.bat:1732
hardcodes 127.0.0.1:11435, while 1.5.8's GEMMA_SEARCH_PORT moves everything
around it. Setting that variable on current main moves the health probe but
not the listener, so search breaks. Found this independently while
reviewing #7 and #11.

What the port needs, per PR

Five of the eight need nothing from the Go port:

Three need real work, all of it on my side of the fence:

  • Rate-limit login and hash the access password with PBKDF2 #6 -- the concrete break, and the reason I checked at all. launch.bat
    exports the secret as GEMMA_ACCESS_SECRET; the Go server knew only
    $argon2id$ and ^hex:hex$, so a PBKDF2 secret makes it refuse to start, and
    set-password can't fix it because env beats file. Not a fail-open — the
    startup gate is what prevents one — but a hard stop. Fixed and merged into the
    port ahead of time: it accepts the format whether or not this PR ever lands.
  • Fix orphaned Windows background processes #15 -- the port has the same class of bug for a different reason. Shutdown
    only runs on defer and signal.Notify, so Task Manager "End task" or a
    runtime fatal leaves llama-server holding the port and the VRAM.
    CREATE_NEW_PROCESS_GROUP makes it worse by detaching from console Ctrl+C.
    Fixed with a JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE job object.
  • Search without an Ollama account: a provider seam inside Handle-Search #11 -- one packaging line, waiting on the PR.

The process from here

When one of these merges, mirroring is: merge main into go_go_gobbonet,
resolve .gitignore if it's #15 or #16, run stage-web.sh if the frontend
moved, and run go build ./... && go vet ./... && go test ./.... The Go side
has no opinion about .bat or .ps1 changes, which is most of this queue.

Happy to rebase this PR onto whatever lands, in whatever order suits you. It
carries no expectation about which of these you take.

VERSION said 1.5.1 while the tree carried upstream's 1.5.8 frontend -- the
ports at launch.bat:134 and :160 are 11437 and 9066, which are 1.5.8's. So
every stamped build reported 1.5.1-go-<sha>, naming a release it was not
built from, from `gobbonet version`, the startup banner and
/health-fileserver at once.

This is the second time. build-release.sh's own comment records the first:
two separate literals drifted to 1.3 and 1.4 while the tree carried 1.5.1.
Consolidating them into one file stopped the literals disagreeing with each
other. It did nothing about the file disagreeing with upstream, which is the
failure that actually reaches a tester.

The release half is upstream's number, not ours, so a test now holds VERSION
to the nearest upstream release tag reachable from HEAD and prints the
correct value when it does not match.

The tag is the check and not the source. Deriving VERSION from it would need
a clone with tags fetched, which a release build cannot assume, and deriving
it from js/01-config.js's CHAT_HTML_BUILD is worse: that string was
'2026-05-16-nemo-strict-template-fix' at v1.5, v1.5.1 and v1.5.3, and only
started carrying a version number at 1.5.4. Where there is no tag to read --
a shallow clone, an export -- the test skips and names what it did not
verify, rather than passing quietly.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Assisted-by: John McCardle <mccardle.john@gmail.com>
ElodineOfficial added a commit that referenced this pull request Aug 21, 2026
@neoliminal - pin SHA-256 of engine + embed model (#8), strip run-flags from transferred state (#5), sanitize GGUF metadata reaching generated .cmd (#3), escapeHtml fallback (#4), firewall / URL ACL scoping (#7).
@wizzense - fall back to loopback when the LAN bind is denied (#10).
@jmccardle - Linux wine catch, roadmaps for Linux (#2).
@DawidKorach - stable port assignments, llama health registration in the CMD (#14, #15).

Co-authored-by: neoliminal <john.kipling.lewis@gmail.com>
Co-authored-by: wizzense <37890504+wizzense@users.noreply.github.com>
Co-authored-by: John McCardle <mccardle.john@gmail.com>
Co-authored-by: Dawid Korach <dawidk6@gmail.com>
Upstream shipped the contributor patches this fork submitted -- the batch
and cmd argument sanitisers, the card-code neutralisation on restore, the
purge that actually clears IndexedDB, the CSS.escape and escapeJsAttr
corrections -- so the frontend arrives here already carrying them and the
merge is a clean fast-forward of files this branch never touched.

Two upstream changes need a Go-side answer and get one in the commits
that follow: web search no longer runs as a separate process on 11435
(fileserver.ps1 serves /search itself), and the model catalogue in
launch.bat changed shape, which installer/models.ini is generated from.
Upstream deleted the search relay: a hidden-window PowerShell started
with -EncodedCommand that bound 11435 and forwarded authenticated
requests to ollama.com. fileserver.ps1 now makes that call itself.

The Go port never started that process at all, so its default
search_url of 127.0.0.1:11435 pointed at nothing on every install and
/search answered 502 whenever a user switched search on. Pointing
search_url at the API is the whole port -- the existing proxy already
strips our session cookie and forwards the browser's own Authorization,
which is exactly what Handle-Search does by hand.

/search/health is the one thing a plain proxy cannot do: the client
probes it before every search and the API has no such route. It is
answered here, and reports configuration rather than reachability --
an unauthenticated probe cannot tell "offline" from "bad key", and the
search request itself reports the real failure a moment later.
1.6.0 clamps it in identify-model.ps1, at the source, because the value
is raw UTF-8 out of a downloaded GGUF and lands in a `set "MODEL_ID=..."`
line of a .cmd the launcher CALLs.

This program cannot be injected that way -- llama-server is exec'd with
an argv slice, and the record reaches disk through encoding/json -- but
the arch string still becomes rec.ID and rec.Family for every model that
falls through to the generic branches, and both travel to the browser in
active-model.json. Clamping at the same place keeps the port readable
against its original and leaves one choke point rather than a rule each
consumer has to remember.
1.6.0 moved the $min VRAM table and the vram -> pick ladder out of an
inline `powershell -Command` one-liner in launch.bat and into
hw-recommend.ps1. gen-catalog.py parsed them out of launch.bat, so it
did what it is built to do and refused to emit a catalogue rather than
one with no VRAM warnings in it.

Regenerated models.ini follows the release: Gemma 4 E4B replaces Gemma 3
4B at slot 1, Qwen3.5 9B replaces Llama 3.1 8B at 4, Qwen3.6 35B-A3B
replaces Qwen3 30B at 6 (gate raised to 24 GB, default context dropped to
8192 so the KV cache fits beside 22 GB of weights), and slots 7 and 8
pick up upstream's fixes for two URLs that 404'd. All ten resolve on
HuggingFace as of this commit.

Also cross-checks $min against launch.bat's PICK_MIN list. They are the
same gate maintained twice in two languages, upstream's own comment says
they must agree, and slots 9 and 10 had no PICK_MIN at all until this
release -- so the menu warned about a threshold the download did not
enforce, in the direction that lets a 19 GB model onto an 8 GB card.
The startup banner printed "[OK] serving on http://..." and then, if the
bind failed, an error underneath it. That reads as a server that started
and then broke, which is the wrong thing to go looking for.

Listen() now binds first and Serve() takes the listener. An in-use port
gets the explanation launch.bat gained in 1.6.0: it is usually a GobboNet
from an earlier run, closing a window does not always stop it, a reboot
clears it -- which is why "custom ports do not work" and "I rebooted and
it fixed itself" are the same report. Naming the holding PID is the one
part not ported; it needs Get-NetTCPConnection and has no portable
equivalent. Windows reports this as WSAEADDRINUSE rather than EADDRINUSE,
so the check covers both.
installer/vendor/llama-cpp is fetched by hand, and the copy on this
machine turns out to be the CPU-only Windows asset: same filenames as
the Vulkan one, minus ggml-vulkan.dll. An installer built from it would
have set gpu_layers 99, offered the same 16 GB models, and run every one
of them on the processor -- no error at any layer, just a machine that
takes a minute to answer, which reads as "this software is slow" rather
than "this build has no GPU backend".

The check is one file test, and LLAMA_BACKEND=cpu is there for anyone
building a CPU-only installer deliberately. No installer is published
from this tree until a Vulkan engine is in place.

@TheAmericanMaker TheAmericanMaker left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The commits in this range represent a clean, robust, and security-conscious update that brings the Go backend and installer in lockstep with the upstream 1.6.0 release. All behavioral contracts and security constraints are well-tested and documented.

@wizzense

Copy link
Copy Markdown
Contributor

Two live reports on Discord line up with something concrete in here — extracted the actual v1.6 .deb release asset and traced both.

"How do I start the LAN launcher on Linux?" — there isn't a separate one, and that's the actual bug. DefaultListenHost = "0.0.0.0", so LAN is already ON by default — but gobbonet-launch always computes URL="http://127.0.0.1:$PORT/" for both its log message and the browser it opens, no matter what listen_host is actually set to. So even on a correctly-LAN-bound install, everything the user sees says loopback. There's no separate LAN entry point to find because the config already does it — the launcher just never says so.

"It says it's open on an IP but I can't connect" — checked HostAllowed() in internal/config/config.go and it looks right (IP literals always pass, .local mDNS allowed, no Secure flag on the session cookie to break plain-HTTP LAN access) — so this one probably isn't in the Go code at all. Most likely a host firewall (ufw/firewalld) blocking the inbound port, which gobbonet check currently has no way to tell them: it only probes the LLM backend (cmdCheck in cmd/gobbonet/main.go), nothing about whether the listen port is actually reachable from outside loopback.

Two real, scoped fixes if useful:

  1. gobbonet-launch should report the address matching the configured listen_host (or at minimum print the LAN IP alongside 127.0.0.1 when bound to 0.0.0.0), not a hardcoded loopback URL.
  2. gobbonet check could dial its own listen_host:listen_port from a second socket (or just say "bound to 0.0.0.0:PORT — if a LAN device can't reach this, check your firewall") — the same class of gap doctor.ps1 closes on the Windows side for the orphaned-listener bug.

Didn't have a Go toolchain handy to build and verify a patch, so posting the diagnosis rather than guessing at one — happy to build it if it'd help.

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.

4 participants