Skip to content

Make fleet enrollment work again on Docker installs - #102

Merged
jhd3197 merged 25 commits into
mainfrom
dev
Aug 16, 2026
Merged

Make fleet enrollment work again on Docker installs#102
jhd3197 merged 25 commits into
mainfrom
dev

Conversation

@jhd3197

@jhd3197 jhd3197 commented Aug 16, 2026

Copy link
Copy Markdown
Owner

The panel has been printing an enrollment command that could not possibly work, and confidently telling operators it succeeded. Issue #101 turned out to be two separate breaks stacked on each other: the Docker image never copied scripts/, so /api/v1/servers/install.sh 404'd in every container, and the installer it would have served still pointed at jhd3197/ServerKit and agent-v* tags — coordinates that stopped existing when the Go agent moved to its own repository. Fixing either one alone just relocates the failure by one step, which is why both land here together. The whole thing stayed invisible because curl -fsSL … | sudo bash reports bash's exit status rather than curl's: a 404 exits 0, installs nothing, and looks like success. Everything downstream of that — the download-then-run one-liner, honest error codes, actual logging configuration, and CI that boots the artifact instead of the source tree — exists because no test could have caught this, and none did. Also included: the offline Marketplace index was pinning seven checksums that don't match their own artifacts, which would have made air-gapped panels reject untampered releases as tampered.

Highlights

  • Adding a server to the fleet works on containerized panels again — the image now ships the agent installers it serves.
  • The agent installer downloads from the repository that actually publishes agent binaries, so enrollment and update checks resolve instead of 404ing.
  • A failed install now stops and says so, instead of exiting quietly having done nothing. The one-liner shown in Add Server, Downloads, and the token dialog downloads to a file first and only runs on success.
  • When the panel is missing a file it needs to serve, it says the panel is broken rather than blaming your URL — and records the exact path it looked in.
  • API errors under /api/ come back as JSON instead of an HTML error page, and unhandled exceptions get logged with the request that caused them.
  • Panels cut off from the network no longer reject correct extension downloads as tampered, and no longer hide an extension an online panel would offer — the bundled fallback Marketplace index carried stale checksums, sat nine entries behind, and was still missing LocalKit Bridge. It is now generated by a script rather than maintained by hand.
  • The installers' own --help prints a command that actually works: it pointed at https://your-serverkit.com/install.sh, a path nothing serves.
  • The roadmap stops under-reporting itself: extension install consent, the WordPress extraction, and the whole game-server family have shipped.
Technical changes

Image packaging

  • Dockerfile copies scripts/install.sh and scripts/install.ps1 into /app/scripts. Only those two — both are standalone and source nothing from scripts/lib, so the rest of scripts/ stays out of the image.
  • .dockerignore's header comment claimed the build "only needs frontend/, backend/, VERSION" while the panel was serving installers off disk; that wrong-but-authoritative note is corrected, along with a warning about why it mattered.
  • _get_scripts_dir() in backend/app/api/servers.py documents each shipped layout it has to resolve and honors a SERVERKIT_SCRIPTS_DIR override for split deployments.

Agent repository coordinates

  • AGENT_GITHUB_REPO (env SERVERKIT_AGENT_GITHUB_REPO) replaces the panel-monorepo GITHUB_REPO for the release lookup, defaulting to jhd3197/serverkit-agent.
  • Tag matching moves from startswith('agent-v') to _AGENT_TAG_RE = ^v(\d[^\s]*)$. The digit is load-bearing: the agent repo carries a malformed release tagged literally v, which parsed to an empty version and produced download URLs like .../download/v/serverkit-agent--linux-amd64.tar.gz.
  • scripts/install.sh and scripts/install.ps1 get the same treatment — repo constant, v${VERSION} download and checksums.txt URLs, and grep -o '"tag_name": *"v[0-9][^"]*"' / -match "^v(\d.*)$" for tag parsing.
  • SECURITY.md and CHANGELOG.md record where the agent lives now and that agent-vX.Y.Z was the monorepo-era scheme.

Failure honesty

  • _load_installer() factors the read-and-stamp path shared by both installer routes so they cannot drift, and returns 503 with searched_path when the file is absent — a missing installer is this panel's packaging fault, not a bad request. The old 404 is what sent the issue reporter hunting for a wrong path.
  • Every rendering of the Linux one-liner switches to curl -o /tmp/serverkit-agent-install.sh && sudo bash …: the API install_instructions payload, Servers.jsx's AddServerModal, ServerSettingsTab.jsx's TokenModal, both Linux entries in Downloads.jsx, and install.sh's own usage/help text.

Error handling and logging

  • New _configure_logging() in backend/app/__init__.py installs a root StreamHandler with [%(asctime)s] %(levelname)s in %(name)s: %(message)s. Services across the codebase call logging.getLogger(__name__) but nothing ever configured the root logger, so records fell through to logging's last-resort handler — WARNING and above, no timestamps, INFO dropped. Guarded by a module-level _logging_configured flag because create_app() runs many times per session and gunicorn imports before forking.
  • Flask's default_handler is removed from app.logger to stop double-printing every record (it propagates to root as well). LOG_LEVEL overrides; TESTING pins INFO so adding a handler doesn't silently promote everything to DEBUG via TestingConfig's DEBUG=True.
  • @app.errorhandler(500) logs request.method/request.path with the original exception and returns JSON. Flask only routes here when not propagating, so the dev server keeps its traceback.
  • The 500 guarantee is asserted through app.handle_exception() — Flask's own entry point — rather than by reading app.error_handler_spec, a Flask internal whose shape has moved between releases. Registration is not behaviour: a handler can be registered and still return HTML or log nothing. The log assertion matches this handler's own wording, because Flask's log_exception() already emits the path before any handler runs, so asserting on the path alone passes even with the handler's logging deleted.
  • @app.errorhandler(HTTPException) returns {'error', 'status'} JSON for /api/ paths — abort(), router 405s, MAX_CONTENT_LENGTH 413s previously handed Werkzeug HTML to clients parsing JSON. Non-API paths keep the standard HTML page; the specific 404/500 handlers still win.

Vendoring the installers

  • scripts/sync-agent-installers.sh re-vendors both installers from a sibling serverkit-agent checkout or from GitHub, normalizing to LF so .gitattributes can apply this repo's *.ps1 eol=crlf policy. Both files gain a CANONICAL SOURCE banner pointing at the agent repo.
  • resolve_agent_ref() asks the GitHub API for default_branch, falling back to probing main then master, with AGENT_REF to pin explicitly. A hardcoded branch was wrong twice in one day (main, then master during a rename) and fails as "cannot reach the canonical copy" rather than "wrong branch".
  • Both installers' usage and --help examples pointed at https://your-serverkit.com/install.sh; the panel serves them at /api/v1/servers/install.sh and nothing answers at the domain root, so anyone copying the command out of --help while troubleshooting got a 404 — the same wrong-path hunt Incorrect paths in setup #101 started with. Fixed in the agent repo and re-vendored, not patched here, which the drift check enforces.

CI gates

  • release.yml builds linux/amd64 and boots it before the multi-arch push, with cache-from: type=gha so the push reuses those layers. The job previously went straight from build to push.
  • release-smoke.yml gains a docker-image-smoke job and widens its path filters from scripts/build-release.sh to scripts/**, Dockerfile, and .dockerignore.
  • scripts-nightly.yml gains an agent-release-chain job that asks GitHub whether the URLs the installer builds resolve to real assets, plus a drift check comparing the vendored installers against the agent repo's canonical copies (EOL-insensitively).

Marketplace fallback index

  • backend/app/data/registry_index.json is regenerated as a straight mirror of serverkit-extensions/index.json. Seven of ten entries pinned a sha256 that didn't match the artifact their own source URL resolves to at the same version, so an offline panel would download a correct release and reject it as tampered. It also sat nine entries and one version behind, and was still missing serverkit-localkit — an offline panel offered a different extension set than an online one. The file's note now states it is a mirror and must not be hand-edited.
  • scripts/sync-registry-index.sh re-vendors it, so "hand-edited" stops being the only option: sibling checkout first, else the published branch with the default branch resolved from the API, same shape as sync-agent-installers.sh. It writes with the exact formatting that round-trips the existing file, so an in-sync run is a true no-op rather than a reformat, and it refuses to write an index with no entries so a truncated source cannot blank the Marketplace.
  • Deliberately vendors the raw index, never https://serverkit.ai/ext/index.json. The panel fetches through that proxy at runtime and the proxy rewrites relative logo paths to absolute URLs, while the bundled loader resolves them itself. The tuple the tests compare (version/source/sha256) is identical either way, so vendoring the proxied copy would bake in wrong paths silently.
  • test_bundled_entries_match_what_the_panel_actually_ships closes the gap the drift test cannot see. A bundled: true entry carries no source/sha256, so every other check skips it — the catalog could promise a version the panel does not ship, or an extension it does not ship at all. Offline and deterministic, so it fails on the PR rather than needing the network.

jhd3197 and others added 15 commits August 15, 2026 04:25
…ervers

Four boxes were unchecked for things that have shipped. Signed extension
releases with install consent landed with plan 55's ed25519 signing service
and the marketplace consent modal. WordPress is out of the tree and installs
from the Marketplace like any other extension. The Minecraft extension, the
reusable gamekit framework and the connect card are all published at v1.0.0
and carried in the registry index.

SRV-record domain support is split out as the one game-server item that is
genuinely still open — grep finds no SRV handling in the extension — rather
than leaving it hidden inside a bullet whose other half was done. The
built-ins bullet now names only what is left, since the WordPress flagship it
called out has been extracted.
… not match

app/data/registry_index.json is what the Marketplace falls back to when the
live registry is unreachable, and what the test suite reads. Seven of its ten
entries carried a sha256 that does not match the artifact their own source URL
resolves to, at the same version: serverkit-gpu's zip hashes 3872d71f..., the
bundled copy claimed c861dafa.... A panel that fell back to this file would
download the correct, untampered release and reject it as tampered, on exactly
the panels already cut off from the network. It also sat nine entries and one
version behind (WordPress 1.0.0 against a published 1.0.1).

Nothing caught it because nothing checked it: the file was hand-maintained,
had no generator, and no test referenced it. A wrong-but-well-formed digest is
indistinguishable from a right one without fetching the artifact or the
published index.

Regenerated as a straight mirror of serverkit-extensions/index.json, and the
note now says it is a mirror and must not be hand-edited. The new test compares
the pinned (version, source, sha256) tuples against the published index and
skips only when the index is unreachable, so offline is not a failure but a
reachable mismatch is. Verified it fails against the old file, naming all seven
digests and the WordPress version, and passes against the regenerated one.

Also corrects the signed-releases roadmap line I marked done earlier today: the
verification path and the consent UX shipped, but no published release actually
carries a signature and the registry schema did not even document the field, so
every first-party install still takes the unsigned-consent path.
The panel serves the fleet-agent installers at /api/v1/servers/install.sh and
/install.ps1 by reading them off its own filesystem, resolving <tree root>/scripts
four dirnames up from app/api/servers.py. In the image that is /app/scripts --
and the Dockerfile only ever copied backend/, VERSION and frontend/dist, so the
directory did not exist. Every containerised panel answered the enrollment
one-liner printed in its own Add Server dialog with a 404, and no server could
join the fleet. Verified against the published image: `ls /app` has no scripts,
and a booted container returns 404 while /api/v1/system/health returns 200.

Bare metal was unaffected -- install.sh git-clones the whole repo, and the
release tarball excludes only scripts/test/output.

Ship just the two installers: both are standalone (they source nothing from
scripts/lib), so scripts/lib, keys, the test harnesses and stage-remote.sh stay
out of the image. The .dockerignore comment claiming the build "only needs
frontend/, backend/, VERSION" was wrong and authoritative, which is a large part
of why this went unnoticed; it now states the real list.

Closes #101 (packaging half).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
When the Go agent moved out of this monorepo into jhd3197/serverkit-agent, the
installers did not follow it. They still asked jhd3197/ServerKit for `agent-v*`
tags -- coordinates that exist in no repository: jhd3197/ServerKit has zero
agent-v* releases, and the binaries are published as vX.Y.Z in the agent repo.
Version discovery therefore failed outright, and the download URL was a 404.

Everything downstream was already correct and is unchanged: the asset name
serverkit-agent-<ver>-linux-<arch>.tar.gz, the entry inside the tarball
(serverkit-agent-linux-<arch>) and the checksums.txt entry names all match the
real v1.2.0 release. Only the repo and the tag prefix had rotted.

Version discovery now requires a digit after the "v". The agent repo carries a
malformed release tagged literally "v", which would otherwise parse to an empty
version and build .../releases/download/v/serverkit-agent--linux-amd64.tar.gz.

test_agent_install.sh encoded the dead scheme in its fixtures (agent-v0.3.2), so
it stayed green throughout. It now pins the release coordinates as a contract:
the repo, the /v${VERSION}/ URL scheme, the absence of any live agent-v
reference, and the checksums URL exercised through the curl stub. All four fail
against the pre-fix script.

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

Three changes to the endpoints that serve the fleet-agent installers.

A missing installer answered 404. That is what sent issue #101's reporter
looking for a wrong URL -- they filed it as "incorrect paths in setup" -- when
the file had simply never been shipped into the image. The status code produced
the misdiagnosis. It is now 503, the response names the path that was searched,
and the same path is written to the panel log, because the response body does
not survive the `curl -f` the enrollment one-liner uses: curl discards it and
prints only "The requested URL returned error: 404". For an endpoint consumed by
curl, the status code and the server log are the only diagnostics that reach
anyone.

The panel's release lookup pointed at jhd3197/ServerKit and filtered for
`agent-v*` tags, which exist nowhere since the agent moved to its own repo, so
/api/v1/servers/agent/version returned 503 to every agent polling for an update.
It now uses AGENT_GITHUB_REPO (env SERVERKIT_AGENT_GITHUB_REPO) and a ^v(\d…)
tag filter that skips the agent repo's malformed "v" release.

_load_installer() is shared by the .sh and .ps1 routes so they cannot drift, and
SERVERKIT_SCRIPTS_DIR overrides the resolved directory for unusual layouts.

New tests cover the endpoints, the release lookup, and -- the one that would
have caught the original bug -- a packaging invariant that parses the
Dockerfile's COPY sources and asserts every installer the panel serves is
actually copied into the image. No endpoint test could have caught it: pytest
runs against the source tree, where the file always exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The command the panel prints for enrolling a server was
`curl -fsSL <url> | sudo bash -s -- …`. In a pipeline the shell reports bash's
exit status, not curl's, so a failed download exits 0: the operator sees one
line of curl noise, the shell reports success, and nothing was installed.
Measured against a 404 -- `curl -fsSL <404> | bash -s -- --token TOK; echo $?`
prints 0; with `set -o pipefail` it prints 22.

Download to a file first and run it on success, so a failed fetch stops the
command and surfaces curl's non-zero exit. Applied to the Add Server drawer, the
server-detail connection modal and the Downloads page.

The Windows one-liner is left alone: `irm` writes an error and the pipeline
stops, so its failure is already visible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tracing issue #101 turned up that a panel failing to serve its own agent
installer left no diagnostic trail at all. The backend had exactly one error
handler -- the 404/SPA one -- no 500 or HTTPException handler, and nothing
anywhere configured logging, while only 2 of 100 app/api modules ever call
logger.error.

Adds:

- an errorhandler(500) that logs the unhandled exception with its method and
  path and returns JSON. Flask routes here only when it is not propagating, so
  pytest and the dev server still re-raise with a full traceback.
- an errorhandler(HTTPException) so framework-generated errors under /api/
  (405 from the router, 413, abort()) return JSON instead of Werkzeug's HTML
  page to clients that are parsing JSON. Non-API paths keep the HTML page, and
  the more specific 404/500 handlers still win for those codes.
- _configure_logging(): a stderr handler with a timestamp/level/logger-name
  formatter, level from LOG_LEVEL. Services across the codebase log through
  logging.getLogger(__name__); with no root handler those records fell through
  to logging's last-resort handler -- WARNING and above only, unformatted, INFO
  dropped on the floor.

Two things the container run caught while writing this: Flask's default handler
has to come off app.logger or every record prints twice (once as %(module)s via
Flask's handler, once as %(name)s via ours, both reaching stderr through
propagation), and the level must not key off DEBUG alone -- TestingConfig sets
DEBUG=True, which would have moved the whole test suite to DEBUG logging as a
side effect. Both are pinned by tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Issue #101 shipped because release.yml went straight from `build` to `push`
with nothing in between. The image was missing scripts/, so every containerised
panel 404'd its own enrollment URL, while the entire test suite stayed green --
pytest runs against the source tree, where the file always exists. Only booting
the artifact catches that class of bug.

scripts/test/smoke-docker-image.sh builds (or takes) an image, asserts the files
the panel reads at request time are present, boots it, and requests every route
that serves a file off disk. release.yml now builds amd64, runs it, and only
then pushes multi-arch (layers are cached, so the push reuses the build).
release-smoke.yml gets the same job on dev and PRs, and its path filters now
include Dockerfile, .dockerignore and scripts/ -- none of which used to trigger
it.

scripts/test/verify-agent-release-chain.sh covers the other half, which unit
tests structurally cannot: they stub curl, so they only ever prove the installer
is self-consistent, never that the URL it builds resolves to a real file. This
one reads the coordinates back out of the script, asks GitHub, downloads the
archive, verifies the checksum and confirms the tarball entry name the installer
moves. It runs nightly, and is also the tripwire for the reverse case -- the
agent repo changing its release naming and breaking the panel from the other
side of the repo split.

Both are plain scripts so the same check runs locally:
  bash scripts/test/smoke-docker-image.sh
  bash scripts/test/verify-agent-release-chain.sh

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Also corrects the scope note, which said the agent is tagged agent-vX.Y.Z and
linked to agent/README.md -- a path that no longer exists. The agent ships from
jhd3197/serverkit-agent and is tagged vX.Y.Z there.

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

scripts/install.sh and scripts/install.ps1 are now VENDORED copies. The agent
repo owns them; this repo carries a copy only because the panel serves them off
its own disk at GET /api/v1/servers/install.sh|.ps1 and cannot link to another
repository.

Two copies of one file is what caused the second half of #101. When the agent
moved out of this monorepo it took its installer with it, and from then on there
were two -- actually four: the agent repo also carried a third, older pair under
its own scripts/ that nothing referenced. Each looked fine in isolation, nothing
ever compared them, and the panel went on serving one that downloaded from
`agent-v*` tags which exist in no repository. Unit tests are structurally blind
to this: they exercise whichever copy lives in their own repo and have no idea
another exists.

So:

  scripts/sync-agent-installers.sh     re-vendors from the agent repo (a sibling
                                       checkout if present, else the published
                                       branch), normalising to LF so this repo's
                                       .gitattributes can apply its own EOL rule
  scripts/test/check-installer-drift.sh fails, with the diff and the fix, if a
                                       vendored copy differs from canonical
                                       (EOL-insensitive: *.ps1 is checked out
                                       CRLF here and LF there, which is not drift)

The drift check runs nightly alongside the release-chain verification, since
both need to reach GitHub. Both scripts run locally against a sibling checkout,
so this works offline and before an agent change is published.

Both installers carry a provenance header naming the canonical location, so
anyone who opens the vendored copy to patch it is told where to patch instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The vendoring and drift-check scripts defaulted AGENT_REF to `main`, which this
repo uses but the agent repo does not -- it has no `main` branch at all.
Verified: raw.githubusercontent.com/jhd3197/serverkit-agent/main/install.sh is
404, /master/install.sh is 200.

The bug was invisible locally because both scripts prefer a sibling
../serverkit-agent checkout and only fall back to fetching. It would have shown
up first in nightly CI, as "could not fetch the canonical copy" -- which reads
as an outage rather than a wrong branch name, so it is worth the comment.

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

The vendoring and drift-check scripts hardcoded a branch name for the fallback
fetch. That was wrong twice in one day: first `main` (the agent repo used
`master`, so the fetch 404'd), then `master` (which is being renamed to `main`).
A hardcoded branch turns any rename into a 404 that surfaces as "cannot reach
the canonical copy" rather than "wrong branch name" — a confusing way to fail
for a check whose whole job is to be believed.

They now ask the GitHub API for the repo's declared default_branch, falling back
to probing main then master if the API is unreachable or rate-limited. AGENT_REF
still pins an explicit ref (a tag, say) when you want one. Verified: the API
currently reports `master`, and it will report `main` on its own once the rename
lands — no follow-up edit here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 16, 2026 17:28

Copilot AI 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.

Pull request overview

Fixes broken fleet enrollment for Docker-based panel installs by ensuring the agent installers are actually shipped in the image and by updating all agent release coordinates to the dedicated jhd3197/serverkit-agent repository (plain vX.Y.Z tags). The PR also hardens operational diagnostics by configuring backend logging and returning JSON-shaped API errors, and it adds CI coverage to smoke-test the built Docker image and validate the agent download chain.

Changes:

  • Copy scripts/install.sh and scripts/install.ps1 into the Docker image and add smoke tests to boot the image and assert the installer endpoints are served.
  • Update backend + installers to resolve agent releases from jhd3197/serverkit-agent with vX.Y.Z tags (skipping malformed v), and update UI one-liners to “download-then-run”.
  • Improve backend logging + API error responses; refresh bundled Marketplace index and add tests to prevent registry drift.

Reviewed changes

Copilot reviewed 25 out of 25 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
VERSION Bumps panel version.
SECURITY.md Documents agent repo/tag scheme change.
CHANGELOG.md Records the enrollment + logging + API error-shape fixes.
ROADMAP.md Updates shipped/remaining roadmap items and “Last updated” date.
Dockerfile Copies agent installer scripts into the image.
.dockerignore Updates documentation to reflect required build-context files.
backend/app/api/servers.py Serves installers via shared loader, injects resolved agent version, switches agent release lookup to agent repo with vX.Y.Z tags.
backend/app/init.py Adds root logging configuration and JSON API error handling for HTTPException + 500.
backend/app/data/registry_index.json Regenerates bundled registry index + fixes metadata note.
backend/tests/test_agent_installer_endpoints.py Adds endpoint + packaging invariant tests for served installers and agent release coordinate behavior.
backend/tests/test_api_error_shape.py Adds regression tests for JSON error shapes and logging configuration.
backend/tests/test_registry_bundled_index.py Adds drift/shape tests for the bundled registry index (offline + optional network check).
frontend/src/pages/Servers.jsx Updates enrollment one-liner to download-then-run.
frontend/src/pages/Downloads.jsx Updates Linux installer commands to download-then-run.
frontend/src/components/serverdetail/ServerSettingsTab.jsx Updates token modal Linux one-liner to download-then-run.
scripts/install.sh Updates agent installer repo/tag parsing and download/checksum URLs; adds vendoring banner.
scripts/install.ps1 Updates agent installer repo/tag parsing and download URL; adds vendoring banner.
scripts/sync-agent-installers.sh Adds script to re-vendor installers from the agent repo (EOL-normalized).
scripts/test/test_agent_install.sh Updates unit tests to the new repo/tag scheme + adds regression checks for malformed tags and checksum URL.
scripts/test/smoke-docker-image.sh Adds Docker image smoke test validating filesystem + HTTP-served installer endpoints.
scripts/test/check-installer-drift.sh Adds cross-repo drift check for vendored installers (EOL-insensitive).
scripts/test/verify-agent-release-chain.sh Adds network validation that installer-built URLs resolve to real GitHub assets.
.github/workflows/release.yml Builds/boots amd64 image before multi-arch push; enables build cache reuse.
.github/workflows/release-smoke.yml Adds docker-image-smoke job and expands path filters to include scripts/Dockerfile/.dockerignore.
.github/workflows/scripts-nightly.yml Adds nightly agent release-chain and installer drift checks.
Suppressed comments (1)

scripts/install.sh:115

  • The show_help() example also uses https://your-serverkit.com/install.sh, which doesn’t match the panel endpoint (/api/v1/servers/install.sh). Consider aligning the example (or explicitly documenting both forms) in the canonical agent-repo copy to avoid confusion.
    echo "Example:"
    echo "  curl -fsSL https://your-serverkit.com/install.sh -o /tmp/serverkit-agent-install.sh \\"
    echo "    && sudo bash /tmp/serverkit-agent-install.sh \\"
    echo "    --token 'sk_reg_xxx' \\"
    echo "    --server 'https://your-serverkit.com'"

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread Dockerfile Outdated
Comment on lines +159 to +161
# Ship the fleet-agent installers. The panel serves these over HTTP at
# /api/v1/servers/install.sh and /install.ps1, reading them off its own disk at
# <tree root>/scripts — which is /app/scripts here. They were missing from the
Comment thread backend/tests/test_api_error_shape.py Outdated
Comment on lines +55 to +64
def test_internal_error_handler_is_registered(app):
"""A 500 handler must exist, or API clients get an HTML page on a crash."""
handlers = app.error_handler_spec[None]

assert 500 in handlers and handlers[500], (
'no 500 error handler registered; an unhandled exception returns '
"Werkzeug's HTML page and is never logged with its request path"
)
assert InternalServerError in handlers[500]

Comment thread scripts/install.sh Outdated
Comment on lines +19 to +22
# Usage:
# curl -fsSL https://your-serverkit.com/install.sh | sudo bash -s -- --token "TOKEN" --server "URL"
# curl -fsSL https://your-serverkit.com/install.sh -o /tmp/serverkit-agent-install.sh \
# && sudo bash /tmp/serverkit-agent-install.sh --token "TOKEN" --server "URL"
#
jhd3197 and others added 10 commits August 16, 2026 13:46
`app/data/registry_index.json` is the Marketplace's offline fallback and a
mirror of serverkit-extensions/index.json on main. The registry gained
`serverkit-localkit` (v0.2.0, bundled) and the mirror was never updated, so
an offline panel would have offered a different extension set than an online
one -- and test_registry_bundled_index::test_matches_published_index failed
on every run.

Copied the entry verbatim into its published position. The rest of the file
already matched published field-for-field; this is the whole delta.
`builtin-extensions/serverkit-localkit/` ships in the panel at the same
version, so `bundled: true` is correct and the entry carries no source/sha256.

Nothing automates this mirror, so it will drift again on the next registry
publish.

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

backend/app/data/registry_index.json is a mirror of serverkit-extensions'
index.json, and nothing automated the mirror -- so every registry publish left
the panel's offline fallback stale until CI complained. That is how
`serverkit-localkit` sat missing from it: an offline panel offered a different
extension set than an online one.

Same shape as scripts/sync-agent-installers.sh: prefer a sibling checkout so it
works offline and before the registry PR is merged, else the published branch,
resolving the default branch from the API rather than hardcoding a name.

Two things it is deliberately careful about:

  - It vendors the RAW index, never https://serverkit.ai/ext/index.json. The
    panel fetches through that proxy at runtime and the proxy rewrites every
    relative `logo` path to an absolute serverkit.ai URL, while the bundled
    loader resolves relative paths itself. Vendoring the proxied copy would bake
    in the wrong paths, and the tuple the tests compare (version/source/sha256)
    is identical either way -- nothing would have caught it.

  - It keeps the local `note` (which does not exist upstream) and writes with
    indent=2/ensure_ascii=True/trailing newline, which round-trips the existing
    file byte-for-byte. An in-sync run is a true no-op, not a reformat.

It refuses to write an index with no entries, so a broken or truncated canonical
source cannot blank the Marketplace fallback.

Verified: no-op when in sync; reproducing the real bug (dropping the localkit
entry) and re-running restores the file byte-identical to HEAD; repins are
reported; the empty-canonical guard exits non-zero leaving the file untouched.

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

A `bundled: true` entry promises an extension that ships inside the panel, so it
carries no source/sha256 -- which means every other check in this file skips it.
test_matches_published_index only proves the catalog agrees with the registry,
not that either agrees with the code, so a bundled entry could name a version
the panel does not ship, or an extension it does not ship at all, and nothing
would notice.

Checks each bundled entry against builtin-extensions/<slug>/plugin.json. Offline
and deterministic, so unlike the published-index comparison it fails on the PR
that introduces the mismatch rather than needing the network.

Only asserted in this direction: a builtin deliberately absent from the catalog
is a product decision, not rot.

Verified non-vacuous by mutation -- claiming a wrong version and naming a
non-existent bundled slug each fail, on different assertions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
User-facing half of the registry-mirror work: an offline panel (or one with the
registry disabled) was missing LocalKit Bridge from the Marketplace because the
bundled fallback had fallen behind the published index.

The sync script and the test guard are tooling, so they stay in git log --
except for the one line about the new check, which is the part a reader of this
file would care about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Picks up serverkit-agent 219e75e, which points the usage and --help examples at
/api/v1/servers/install.{sh,ps1} instead of the domain root. Produced by
scripts/sync-agent-installers.sh, not hand-edited — scripts/test/check-installer-drift.sh
fails on any divergence and confirms these match the canonical copies again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
test_internal_error_handler_is_registered read app.error_handler_spec, a Flask
internal whose shape has moved between releases, and proved only that something
was registered. A handler can be registered and still return HTML or log
nothing, so the test could not fail for either way the guarantee actually breaks.

Now driven through app.handle_exception(), Flask's own entry point for an
unhandled exception, asserting the response is JSON and that the request was
logged. Not done by registering a route that raises: the `app` fixture is
function-scoped but wraps a session-scoped Flask object, so the route would leak
into every later test and collide on re-registration. PROPAGATE_EXCEPTIONS is
forced off for the call because TESTING=True turns it on, which re-raises before
any handler runs.

The log assertion matches this handler's own wording rather than just the path.
Flask's log_exception() already emits "Exception on /path [GET]" before any
handler runs, so a path-only assertion passes even with the handler's logging
deleted — confirmed by mutation, which is how the first version of this test was
caught being vacuous. Both mutations (return HTML, drop the log) now fail, on
different assertions.

Raised by a reviewer on the PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The comment explaining why scripts/ is copied into the image read
"/api/v1/servers/install.sh and /install.ps1", which reads as though the Windows
installer is served at the domain root. It is not — both live under
/api/v1/servers/. A comment whose whole job is to stop the next person hunting
for a wrong path should not contain one.

Raised by a reviewer on the PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jhd3197
jhd3197 merged commit 4accf32 into main Aug 16, 2026
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.

2 participants