OpenAPIService.generate_spec() existed but nothing snapshotted it, so a
route rename/removal/method change shipped invisibly to API-key
consumers, the CLI, and agents. docs/API_SURFACE.md now pins the sorted
METHOD /path inventory (1418 operations) with the same
regenerate-and-review contract as docs/MIGRATION_INVENTORY.md: an
intentional surface change regenerates the doc in the same commit
(SERVERKIT_UPDATE_API_SURFACE=1 pytest
tests/test_api_surface_inventory.py) so the diff is the review.
Method+path only on purpose — summaries and schemas churn without
breaking anyone. Named *_inventory so preflight's globs run it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The panel's crontab/nginx code only ever met the fake_subprocess stub —
the 8-distro matrix runs stubs and the container legs run 12 detection
tests, so a syntax the real parser rejects failed on a user's box, not
in CI (the #117 class).
- new `real_binaries` marker (registered in conftest); the tests also
gate on SERVERKIT_REAL_BINARIES=1 so ordinary suite runs never mutate
a machine's crontab by surprise. Local loop (WSL works):
SERVERKIT_REAL_BINARIES=1 pytest tests -m real_binaries
- test_real_crontab_roundtrip: add -> list -> toggle -> remove through
the real `crontab -`/`crontab -l`, operator lines preserved, name
injection blocked; user crontab saved/restored around each test
(verified green on WSL against the real binary)
- test_real_nginx_vhost_syntax: every render_site_config flavor through
a real `nginx -t` in a tmp wrapper conf, plus a garbage probe so the
harness can't go vacuous
- backend-ci gains a real-binaries job (ubuntu runner, apt nginx+cron)
- scripts-ci gains the probe-matrix job: scripts/test/probe-matrix.sh
(real ufw/nginx/firewalld in 4 container shapes) existed since plan
75 but was wired into NO workflow; the ubuntu-root-sbinless leg was
re-verified green locally before wiring
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
version and pool_name flow from the URL straight into a filesystem path
handed to privileged write/rm — a traversal pool_name could delete an
arbitrary *.conf via run_privileged(['rm', ...]), and a hostile version
escaped the pool.d directory the same way. Both are now validated at
the service door (create/delete/get share the helpers).
_parse_pool_config returned {} on any read error, so an unreadable
pool listed as "user: www-data, pm: dynamic" — fabricated defaults.
It now returns None and the listing reports the honest unknown
(user/listen/pm null + unreadable: true), per the probe-honesty rule.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The jail filename derives from the site name at enable time, so a site
renamed afterwards orphans its jail: disable_jail derives a path that
no longer exists (honestly reports removed: False) while the stale
jail keeps banning against a log nginx no longer writes — invisible
to every name-derived lookup.
- list_jails() scans JAIL_DIR for serverkit-*.conf and parses the site
label + logpath out of the rendered header, the only view that still
sees orphans
- cleanup_orphans(active_keys) removes ServerKit jails no live key
derives any more (operator files never touched), reloading once
- the _sanitize collision (my.site / "my site" -> one shared jail) is
pinned as documented behavior: changing the derivation would orphan
every existing install's jail files
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Drift byte-compared the live vhost against the bare template render,
but an app with an enforcing WAF policy carries the post-render
include — so every WAF'd app read as permanently drifted, and one
"repair" click regenerated the vhost without the include: silently
unprotected while the DB still said enforcing.
- the injector is now a text-level function (inject_include_text) so
the live wiring and drift's expected render share one implementation
- _nginx_render_expected injects the include for enforcing policies,
making a correctly-wired vhost compare clean
- _nginx_repair re-runs WafService.apply after regenerating, so a
genuine repair re-wires protection instead of stripping it
Basic auth (WordPress-site-scoped) and the analytics tracker
(plugin-scoped) have the same erase-on-regen exposure and are tracked
in plan 82 §H — their state lives outside the app row this check walks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tests/subprocess_stub.py is the shared stubbing kit — hard-failing
UnscriptedCommand, sudo/basename normalization, sbin-guard still in
force — but adoption stalled half-way: 26 sites across 9 test files
still monkeypatch subprocess by hand, execs the runtime guard never
sees. Every other convergence here has a census + ceiling; this one
did not. Same contract as the rest: AST-counted over tracked files,
ceiling 26, may only go down; patching a service door
(run_privileged etc.) is the blessed pattern and not counted.
Preflight picks both files up by its existing globs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The test suite's schema comes from db.create_all() (see conftest), so
none of the 93 alembic revisions had ever been executed by a test — a
model column with no migration keeps every test green while every real
upgrade dies with "no such column". New test runs `flask db upgrade`
against a throwaway sqlite file in a subprocess and diffs the migrated
schema against db.metadata in both directions.
First run caught real drift: migrations 059/065/068 create columns
(events_json, management_mode, allow_agent_update_observed) that the
models had lost in the recovery rebuild — restored in the previous
commit, which also brought two 500ing survey routes back to life.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Migrations 065/068 have always created servers.management_mode and
servers.allow_agent_update_observed, but the model fields,
MANAGEMENT_MODES, is_managed, and agent_registry.observed_blocked_count
were lost in the 2026-07 data loss — POST /management-mode and
GET /observed-status raised AttributeError, and a mode flip that got
past validation would land on a transient Python attribute and
evaporate. test_survey_api carried skip-stubs documenting the hole.
Restored: the two columns (specs mirror the migrations), the mode
constant and is_managed property, to_dict emission, and a minimal
observed-block counter on the registry. The four skip-stubs are now
real tests, including one proving the flip persists across sessions.
The dispatch-level Observe guard remains hollow and is tracked in
plan 82 §H. events_json on notification_preferences is likewise
re-mapped (column carried by migration 059; its resolution surface is
still lost) so pre-loss override data survives.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Nothing verified that the ~1200 URL literals in
frontend/src/services/api/*.js resolve to rules in the backend url map,
so a backend route rename surfaced as a dead button found by a user.
New test statically extracts every this.request() literal (mirror of
test_socket_contract.py's cross-language pattern) and asserts each
matches an /api/v1 rule from the session app's route_rules; paths under
a builtin extension's plugin.json url_prefix are excused only while
that manifest still declares the prefix, and a second test keeps the
allowlist from rotting.
First run caught 78 unresolved paths. Extension-owned families
(email/ftp/cloud/status/tunnels) were excused; the rest were dead
frontend code calling routes that no longer exist, removed here:
- 11 workflow wrappers in apps.js (the workflow engine left the
backend in v1.7.33; zero callers remained)
- notification catalog/org-defaults wrappers (routes never existed;
zero callers)
- 6 WordPress standalone lifecycle wrappers (no routes, no callers;
the live status probe stays, allowlisted as extension-provided)
Frontend suite still green after the deletions (141/141, lint clean).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The frontend's 141 node --test assertions were invocable but invoked by
no workflow — written insurance never cashed in. frontend-ci now runs
npm test after lint (verified green locally: 141/141 in ~2s).
release.yml's ci-gate covered backend-ci only, so a release could ship
with a red frontend or drifted builtin extensions. The gate now also
requires frontend-ci and extensions-ci (both fast, both already
workflow_call-able). scripts-ci stays ungated on purpose — its
update-e2e leg takes ~45 min — with a comment showing how to add it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Locks in the round-trip suites added for the #117-class fixes (ssh
keys, env export/import, waf vhost injection, ufw parser parity,
postfix maps) plus tests accumulated since the floor was last set.
Measured with SERVERKIT_CLEAN_COLLECT=1.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
add_domain deduped with a raw substring test, so with sub.example.com
already mapped, adding example.com was a no-op that still reported
success and mail for the new domain never routed. remove_domain
filtered alias lines with a bare `@domain in line` test, which also
deleted example.com.br entries. Appends went through tee -a with no
trailing-newline guard, so a truncated map glued the new entry onto
the last line, corrupting both and making them unremovable.
- add_domain dedupes on the line's domain field
- remove_domain drops only lines whose address tokens are at exactly
that domain (com.br superstrings and sub.example.com both survive)
- map appends heal a missing trailing newline first
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
_get_ufw_rules and _ufw_numbered_rules each parsed `ufw status numbered`
with their own regex, and the listing's omitted LIMIT — a `ufw limit
22/tcp` rule was invisible in the panel's rule table while the
SSH-removal guard still counted it, so delete-by-number targeted rows
the user could not see. Both paths now match with the shared module
_UFW_NUMBERED_RE, so the table and the guard always agree row for row.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
_inject_include inserted after the first "server {" in the vhost. On an
SSL site the first block is the HTTP->HTTPS redirect from
SSL_REDIRECT_TEMPLATE, so ModSecurity was wired into a server that only
issues 301s while the real HTTPS server stayed unprotected — and
apply() still reported wired: True. The micro-cache injector already
guards this exact trap; the WAF include now does too: redirect-only
blocks (a 301 return with nothing served) are skipped and the include
lands in the first block that actually serves the app.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
export_to_env_format escaped backslashes and quotes that parse_env_file
never unescaped, so every export -> import cycle grew literal
backslashes in the value; newline values were written as raw multiline
quotes the parser mangled; and the user-supplied description landed as
a raw comment line, so a newline in a description injected brand-new
KEY=value pairs on re-import.
- exported values now escape \, " and newline onto a single line, and
the double-quoted import path decodes exactly those three sequences
(unknown sequences like C:\path stay as written)
- descriptions are whitespace-collapsed before being written as a
comment, the same hardening the cron #117 marker got
- a None value exports as empty instead of raising TypeError
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
get_ssh_keys numbered keys over raw authorized_keys lines (comments and
blanks included) while remove_ssh_key popped that id from a list of key
lines only, so any comment banner shifted every id and delete-by-id
removed the wrong key — or refused the last one as "Invalid key ID".
The rewrite also hoisted all comments to the top, shifting ids again.
- both sides now index the same sequence: non-blank, non-comment lines
in file order; delete drops the line in place so comments keep their
position
- add_ssh_key dedupes on the key-data token instead of a raw substring,
and normalizes a missing trailing newline before appending so a new
key can't be glued onto the previous line
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Creating a named cron job on Linux wrote the display name into the
"# ServerKit Job:" marker comment and list_jobs re-parsed the crontab
under synthetic cron_<index> ids, so the metadata store (name,
description, enabled, application_id) never joined back: the job listed
as unnamed and every mutation route answered "Job not found" — the only
way out was crontab -e.
- add_job now always writes the job_id into the marker comment (the
name rides along, whitespace-collapsed so it cannot inject lines)
- list_jobs recovers the id from the marker, falls back to a
schedule+command match for pre-fix lines whose marker holds the name,
and backfills metadata-only jobs so disabled (commented-out) jobs
stay visible and manageable
- remove_job/toggle_job also match shim-wrapped (tracked) lines, and
remove drops a legacy marker comment left above the deleted line
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The PUT route guarded description with 'is not None', so JSON null never
reached EnvService.update_env_var — whose _UNSET sentinel explicitly
supports clearing. Key-presence check now forwards the null; absent keys
still leave the field untouched. Raised in PR #118 review.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RestorePoint hand-rolled json.loads for payload/coverage — now reads
through JsonColumnMixin._json_read like every other model, keeping the
models-json ratchet green.
test_restoring_the_last_domain_re_enables_the_site mocked create_site
and counted enable_site calls, but since write_vhost became the one
enabling door (enable=True re-links sites-enabled and create_site goes
through it), write_app_vhost no longer calls enable_site separately —
test_site_domain_writer_does_not_enable_twice pins that. The restore
test now asserts through write_vhost's enable flag instead, still
failing if a restore path ever opts out of enabling.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The three restore-point read routes authenticated with bare
@jwt_required(), pushing the census to 579 against a 577 ceiling.
They now use the auth_required() policy door like the file's mutating
routes (which already accept API keys via @developer_required), keeping
read access consistent with write access for API-key callers; scope
authorization stays in-route. test_restore_point_env.py hand-rolled
Application/User seeds — switched to the make_application/make_user
factories. Migration inventory regenerated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ConfigDiffModal renamed noChange to noChangeValue and DeploymentTimeline
renamed envVar to envVarCount, but the old keys were left behind in the
16 translated locales, failing the i18n lint orphan check.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adjust release cleanup/packaging to stop stripping image files from frontend/dist, which broke Vite hashed asset references at runtime. The script now excludes dist from image deletion, removes image-wide tar excludes, and adds a bundle integrity check that fails the build if referenced /assets files are missing.
Reserve the word Recipes for installers. The status-bar segment, dock
tab, hub aria-label and command-palette category now read Walkthroughs
(new app.walkthroughs.title/all keys; stale es allRecipes dropped).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The curated serverkit-recipes index is consumed like the themes registry
(cached remote index, bundled fallback, SERVERKIT_RECIPES_REGISTRY_URL
override). GET /recipes/registry[/<slug>] browses it; POST /recipes/runs
now accepts a registry_slug plus server_id — targeting a server finds or
creates the per-workspace Recipes project — and validated non-secret
params that pre-supply handoff inputs so only true secrets pause the run.
Frontend: Recipes catalog page (cards, category chips, install drawer
with running-server picker, preflight table, up-front inputs and an
asked-during-the-run secrets preview) under new Services and Marketplace
tabs; runs land in the deployment console.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
en.json regenerated from inline defaults (new statusbar/console/palette/
recipes strings; Prompture and old walkthrough-hub strings gone). es picks
up translations for all 26 new keys; every locale drops the now-orphaned
keys so the parity gate stays green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Gradient fill (accent-bright to accent), accent glow, inner top highlight,
and a hover that lifts the gradient toward white — all from theme tokens so
runtime skins keep recoloring it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The palette's empty state now leads with a Create tile grid (shared list in
data/createItems.js) plus a '+ n more' shelf that folds in the less-common
targets and any extension-contributed 'New ...' commands, then a Recipes
group (picking one starts it and opens the recipes console). Create and
recipe entries also score into regular search results.
The sidebar '+' stops carrying its own little dropdown and opens this
palette instead — one door for every new-thing flow, like the prototype.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The right-side chat drawer now carries the shared console tab strip, so the
assistant reads as the right-docked tab of the same console the bottom
panels belong to (its close moved into the strip). The 'powered by
Prompture' lines are gone from the header and empty state — the empty state
now tells the user what to ask instead.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>