50 Commits
Author SHA1 Message Date
Juan Denis 386cd6aa3a fix(connect): stop the client loop when poll mode learns of revocation
_poll_session reports 'revoked' when ServerKit Cloud refuses the poll
with the revoked reason, but the loop only honored 'stopped' and fell
through to re-probe the websocket: ws refused, poll revoked, ws
refused — forever, and the panel never wrote the revoked state. This
is the path a panel behind a close-code-stripping edge depends on, old
builds unconditionally and current ones whenever they are in limited
mode. The poll outcome now gets the same terminal handling as the
websocket one.
2026-09-12 15:09:28 -04:00
Juan Denis 60f8bd8c60 fix(connect): learn revocation from the relay's close-reason frame
The production edge strips non-standard WebSocket close codes, so the
relay's 4009 never reached the panel: a revoked panel kept reconnecting
forever instead of stopping. ServerKit Cloud's relay now sends the
reason as a text frame before closing, and the panel honors it both
during the hello exchange and mid-session, ending the session as
revoked either way. The numeric code still serves direct connections,
and the long-poll fallback's HTTP refusal is unchanged.
2026-09-12 06:31:50 -04:00
Juan Denis fda418d5fa fix(connect): refetch the JWKS when a command arrives signed with an unknown key
ServerKit Cloud mints each signing key the first time it is used and
rotates keys on its own schedule, so a command can legitimately arrive
signed with a key the connection's cached JWKS predates. The panel used
to refuse it as untrusted; it now refetches the JWKS once on an unknown
kid and only refuses a key that is still unknown afterwards.
2026-09-11 22:16:40 -04:00
Juan Denis 1f652adab4 fix(connect): surface silent metrics collection failures
A metrics build that keeps raising, and a sampler that keeps producing
nothing, both used to log at debug level: ServerKit Cloud showed no
telemetry and the panel had nothing to act on locally. A failing build
now warns on the first failure of a streak and logs its recovery, and
five consecutive empty collects warn that the panel sampler may not be
running. The healthy path stays quiet.

Raises the test-count floor to 5271 (check_test_count.py --update): the
three new publisher tests plus the floor never having been bumped for
the 63 cases added since the last update.
2026-09-11 19:08:37 -04:00
Juan Denis 0fc47c3d3e fix(ai): normalize usage quota scope IDs [skip version] 2026-09-08 09:34:35 -04:00
Juan Denis 172e410ce3 chore: prepare 1.11.0 and refresh release inventories [skip version] 2026-09-08 09:31:21 -04:00
Juan Denis d5577e8bda fix(logs): keep remote runtime lookups off the panel host 2026-09-07 22:46:16 -04:00
Juan Denis 0f32593a38 Raise backend test baseline for PR #136/#139 tests 2026-09-07 20:11:45 -04:00
Juan DenisandClaude Opus 5 ade0088777 Make fleet monitoring actually monitor
Three defects, found together because they are the same failure wearing
three hats: monitoring that looks configured and is not.

Fleet alert thresholds were never evaluated. check_fleet_thresholds was
written, was correct, and had no caller anywhere in the backend, so a
threshold set on the Fleet page was checked against nothing and a server
could sit at 99% CPU for a week in silence. It now runs every minute as a
built-in scheduled job alongside every other recurring task, and each
alert it opens or resolves goes to admins through the Notification Bus.
An evaluation nobody is told about is most of the way to no monitoring.

The agent heartbeat was throwing metrics away. It built its own
ServerMetrics row and filled five of the model's thirteen columns,
dropping memory_used, disk_used, the network totals, both network rates
and extra. The two rates are exactly what the threshold checker reads, so
a network threshold could never fire for an agent-reported server: the
data needed to evaluate it was discarded on the way in. Heartbeats now go
through ServerMetricsService, the writer everything else already used.

Metric history outlived its own retention. The history and aggregate
endpoints offered 7-day and 30-day windows while cleanup deleted every
sample after seven days, and two constants described hourly and daily
aggregate retention that nothing produced. A 30-day chart came back
holding at most seven days and said nothing about it. Both endpoints now
return a coverage block — the retention in force, the earliest point they
can answer for, and whether the window asked for was truncated — and the
two constants that promised retention nobody implemented are gone. Real
rolled-up aggregates are the other way to fix this and would need their
own tables and writer; until they exist, saying what we have is the
honest answer.

Two more things surfaced while writing the tests.

get_aggregated_metrics grouped by date_trunc, which is PostgreSQL's alone,
so the endpoint raised "no such function: date_trunc" on every SQLite
deployment — one of the two supported databases. Buckets are now built
with the right function for the database in use.

And an escalation is not a recovery. warning -> critical closes the
warning row, so both a genuine recovery and a worsening breach end up as
status='resolved'; announcing the second as "recovered" would tell an
operator the problem went away at the moment it got worse. Supersession is
tracked separately and is not notified twice, because the new alert
already says what happened.

The two built-in-job counts in test_jobs.py move from 16 to 17 and 24 to
25, and BASELINE_COUNT is raised for the new tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 21:04:17 -04:00
Juan Denis d67e5d07d0 fix: complete audit follow-ups and add regression coverage
Centralize backup schedule calculations and shared request/form flows, encode query values, and move repository finalization into its service. Add browser CI coverage, repair stale test fixtures, and provide opt-in request and build measurement tools.
2026-09-05 14:41:15 -04:00
Juan DenisandClaude Opus 5 7b9eb43a9a test: lower the count floor for the extensions that left the tree
The cutover removes four builtin extensions, so collection legitimately
drops. Verified against dev by diffing collected node ids rather than
trusting the delta:

  -7  test_extension_manifests  the 7 plugin.json files of the four
                                departing extensions (builtin + frontend)
  -4  test_builtin_extension_drift  anchor swap, email (7 py files) ->
                                    status (3)
   0  test_files_rbac / *_extraction  param-id churn only

No feature lost its proving tests. 4799 -> 4791.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 22:12:05 -04:00
Juan DenisandClaude Fable 5 2bdddd4d77 chore: regenerate ratchet inventories after the git split
Controller-boundary baseline drops the three api/security.py crossings that
moved to serverkit-image-scan; API surface sheds the 18 Gitea operations;
migration inventory regenerated by preflight; test-count floor raised
4735 -> 4799 (SERVERKIT_CLEAN_COLLECT).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 14:35:38 -04:00
Juan Denis 9b813b4166 fix(cron): match job lines exactly 2026-08-27 20:30:17 -04:00
Juan DenisandClaude Fable 5 ab13972cfc chore(tests): raise BASELINE_COUNT floor 4687 -> 4732
Locks in plan 82's suites: route contract, migration schema drift,
restored survey observed-mode tests, stub-adoption ratchet, php pool
guards, fail2ban orphan sweeper, WAF drift/repair, real-binaries
crontab + nginx legs, API surface inventory. Measured with
SERVERKIT_CLEAN_COLLECT=1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 05:08:17 -04:00
Juan DenisandClaude Fable 5 d3a7d4bd73 chore(tests): raise BASELINE_COUNT floor 4519 -> 4687
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>
2026-08-27 04:20:27 -04:00
Juan DenisandClaude Opus 5 1c659824c1 fix(tests): the create_app guard must read code, not prose
The plan-77 G3 guard grepped raw file text for `create_app(`, so it counted
mentions. test_disk_reclaim.py failed CI for a docstring reading "Stand-in for
create_app() -- the disk command only needs a context": that file monkeypatches
the factory away and never boots anything. The same false positive had already
put test_api_error_shape.py into the baseline, for a comment.

Detection is now AST-based -- a real call, direct or attributed. String
literals still count, because test_ai_lazy_import.py boots its probe app from an
embedded script; docstrings and comments never boot anything, and an
unparseable file falls back to the old blunt check rather than passing free.

Baseline drops test_api_error_shape.py (it uses the shared `app` fixture) and
gains nothing. test_boot_detection_reads_code_not_prose proves the scanner
against both false positives and all three true positives, so the next docstring
does not turn CI red. BASELINE_COUNT +1 for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 20:14:34 -04:00
Juan DenisandClaude Opus 5 2ad6720bd0 fix(ci): make the test-count ratchet count what a clean checkout collects
The floor was --update'd from a dev box again, so CI collected 4516 against
a floor of 4530 and the ratchet went red for a suite that lost nothing.
Same 14 cases as last time (4431 -> 4417): test_builtin_extension_drift.py
parametrises over the on-disk intersection of builtin-extensions/<ext>/backend
and app/plugins/<ext>, and four live copies here are gitignored --
cloud-provision (3), ftp (3), remote-access (5), status (3).

Fixing the number alone would queue up a third occurrence, so the
machine-dependence is closed instead: check_test_count.py now collects with
SERVERKIT_CLEAN_COLLECT=1, and under that flag the drift test pairs only
git-TRACKED live copies. Ordinary runs are untouched and still compare the
gitignored copies on the machine that has them.

Verified with the WSL venv: the ratchet now reports 4518 locally, exactly
CI's 4516 plus the two tests added here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 19:40:42 -04:00
Juan DenisandClaude Opus 5 406d7c3ff1 chore(tests): raise the test-count floor to 4530
The ratchet only fails on a DROP, so a floor left un-bumped never
complains -- it just quietly stops protecting anything. At 4417 against
4530 collected, 113 tests could have been deleted with CI still green.

113 new tests since the floor was last set: ~35 from the disk-space work
(test_disk_reclaim.py plus the disk --json case) and the rest from the
branch merged into dev.

Verified trustworthy before committing: `pytest tests --collect-only`
(what backend-ci.yml runs) and the bare `pytest` that check_test_count.py
uses both report 4530 -- backend/dev-data/ errors out of collection
rather than adding to it, so the two agree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 19:26:47 -04:00
Juan DenisandClaude Opus 5 0d42baf678 fix(tests): set the test-count floor to the clean-checkout number
4431 was measured on a dev box that has four git-ignored extension copies
installed under backend/app/plugins/ (cloud-provision, ftp, remote-access,
status). test_builtin_extension_drift.py parametrizes over the on-disk
intersection of builtin-extensions/<ext>/backend and app/plugins/<ext>, so
those four contribute 3+3+5+3 = 14 cases that no clean checkout can collect
— CI collects 4417, and the ratchet went red for a suite that had lost
nothing.

This is the failure mode check_test_count.py's own docstring warns about (a
floor --update'd above what a clean checkout can reach). 4417 is still a
raise of 108 over main's 4309, matching the 107 tests this branch adds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 22:09:59 -04:00
Juan DenisandClaude Opus 5 7aa3286932 test(authz): default-deny backstops plus proving tests for the gates
Two nets, deliberately complementary, because neither alone would have caught
the advisory:

- `test_route_authz_sweep.py` fires every mutating route as a viewer and
  fails on any 2xx outside a reviewed `VIEWER_WRITABLE` list. It proves
  *enforcement*, but a route that 400s or 404s on a dummy request before its
  gate runs is invisible to it — `POST /workspaces/` 400s on an empty body,
  so the net would never have seen the bug it exists for. Hence the targeted
  Part A: the fixed routes must answer a viewer and a developer with 403, and
  must not block an admin.
- `test_route_authz_static.py` proves *coverage* from the source instead: every
  mutating route in `url_map` either carries a `_sk_authz` decorator, reaches
  an authorization primitive through its module's call graph, or sits in
  SELF_SCOPED / PUBLIC_ALT_AUTH / STATELESS with a written justification.
  Staleness checks keep an allowlist slot from outliving the route that
  earned it, and a SELF_SCOPED entry must still resolve the caller's identity
  — calling `get_current_user()` is necessary but not sufficient, which is
  exactly how `create_workspace` looked self-scoped while creating a global
  row.

Both nets are blind below the API layer and to public routes, so
`test_authz_round3_guards.py` covers the three fixes whose shape they cannot
express (API-key scope widening, demo-deploy job creation, nginx diff
containment), the webhook cases land beside the rest of the gateway surface,
and the shared-resource tag gate lands with its own blueprint's tests. Each
comes with its non-vacuity case: the same call succeeds for a caller the gate
admits.

`migrations/history` gets tests because it had none — which is why the
NameError in it shipped.

Ratchets: JWT-only census 605 -> 577 (the routes that moved to policy
decorators), the controller-boundary baseline loses the one persistence
crossing `complete_onboarding` no longer makes, and the collected-test floor
rises to 4431.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 21:44:45 -04:00
Juan DenisandClaude Fable 5 1a503240ba fix(ci): scope the test-count guard to tests/ and correct the inflated floor
The guard ran a BARE pytest --collect-only from backend/, so --update on a
dev box swept git-ignored dev-data/ into the count and wrote a floor (4591)
that no clean checkout can collect — CI collects 4309, and the ratchet job
has been permanently red since 2026-08-18, which is a guard nobody can act
on. The floor's history shows the inflation entered at e6e605d1 (+314 with
only +46 test defs in the range it spanned).

Collection is now scoped to tests/ (matching what the CI shards run), a
non-zero collection exit refuses to report a partial count instead of
silently deflating it, and the floor is LOWERED 4591 -> 4309 — the value a
clean checkout actually collects; a dev checkout with sibling-repo
extension tests collects 4324, still above the floor. This is a deliberate,
documented decrease: no tests were removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 10:09:21 -04:00
Juan DenisandClaude Fable 5 a3e907e97f docs(plan-77): completion record — decisions, scope calls, retained tail
All workstreams A-G executed 2026-08-19; open questions resolved
(D1 read-edge normalize, C2 permanent dual-read on the one key, B2
full-dict + _query shaping, E1 dual-emit); deliberate tail named with
owners (Server soft-delete pass, template/security_feed remote-index
non-adopters, the two raw log channels). BASELINE_COUNT 4460 -> 4591.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 05:50:35 -04:00
Juan DenisandClaude Fable 5 5aab23c19c docs(plan-76): completion record — decisions, commits, retained duplication
Names the resolved A and C decisions, the 2026-08-19 commit set, the
intentionally retained semantic duplication with owners and removal
conditions, and the deferred H items. Collection floor 4430 -> 4460.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 00:21:20 -04:00
Juan DenisandClaude Fable 5 e6e605d1f4 refactor(remote): close the dispatch_agent_command seam
Migrate all 20 direct agent_registry.send_command call sites (api/servers,
terminal, fleet doctor/repair/fleet incl. the upgrade Thread target,
deployment_runner, survey, tunnel broker/publish, plugins SDK) onto the
shared dispatcher, and widen the ratchet from remote_*_service.py to a
tree-wide AST scan covering builtin-extensions/. The survey test's
transport stub gets the transport's real keyword signature.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 23:31:46 -04:00
Juan DenisandClaude Opus 5 28d26a73a2 refactor: finish the §G1 subprocess migration (census 195 -> 28)
Plan 75 §G1, the remaining 33 files. Every raw subprocess call site in
backend/app that can go through a door now does. What is left is what
should be:

  8  app/utils/system.py        the doors themselves
  8  database_service           mysqldump|gzip, gunzip|mysql, pg_dump|gzip,
                                gunzip|psql — a pipe is two processes
  7  build/docker/db_sync/ftp   streaming output, gzip pipes
  1  log_service                tail -f
  2  serverkit-analytics        sibling repo; see below

Extensions were migrated in BOTH trees. builtin-extensions/ is the source
and backend/app/plugins/ the live copy, compared by normalised hash;
test_builtin_extension_drift stays green. serverkit-email has both sides
tracked, serverkit-ftp and serverkit-cloud-provision only the source.

serverkit-analytics (2 calls) is deliberately untouched: it has no source
in builtin-extensions/ at all, and backend/app/plugins/serverkit-analytics
is a gitignored materialised copy. Editing it would be a change that
cannot be committed and is overwritten on the next sync — that fix belongs
in the serverkit-analytics repo.

Exit-code semantics are preserved wherever they carry meaning, with a
comment at each site, because they are the thing a mechanical migration
destroys: clamscan 1 = INFECTED, grype 1 = vulnerabilities FOUND, yara
1 = matches, cron_runner 127 = never launched. Those read
result['returncode'], never result['success'].

run_checked gained two things real call sites needed: it yields when the
caller directs the streams itself (writing straight to a log file), rather
than adding capture_output on top and raising ValueError; and text/errors
pass through, so site_importers/ssh keeps decoding with errors='replace'
instead of gaining a UnicodeDecodeError on a bad byte.

A regression this migration introduced, caught by the suite and fixed:
install_profile_service._docker_usable() is deliberately TRI-STATE —
None means the probe could not run, because inside LXC the docker client
is usually present while the daemon never started, and callers "must not
treat a broken probe as proof of absence". Writing it as
run_checked(...)['success'] turned an exec failure into False, i.e. "docker
is not usable" — a fact nobody established. That is the §A violation this
plan exists to remove, reintroduced by the migration meant to prevent it.
test_probe_honesty_doctor caught it. Every other run_checked(...)['success']
caller was then audited; the rest are genuinely two-state, and their
originals also returned False on a missing binary.

Seven more slips of the same shape were caught before running by an AST
check for `x = run_checked(...)` followed by `x.stdout/.stderr/.returncode`
— a dict read as an object, which raises only on the failure path, so
happy-path tests stay green.

api_controller_boundary_baseline.json: pure deletion. Routing api/docker.py
through DockerService removed the last 6 subprocess crossings, so the
`subprocess` category is gone from the API layer entirely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 16:38:25 -04:00
Juan DenisandClaude Opus 5 7383820085 refactor(docker): route docker_service through the shared doors
Plan 75 §G1, second migration. docker_service was the heaviest file in the
census: 49 raw subprocess call sites -> 2. Census 258 -> 211.

DockerService.run now SITS ON run_checked instead of re-implementing it, so
G3's runner and G1's door are one thing rather than two. Adds
DockerService.run_compose() for the compose subcommands, whose argv head is
whatever _get_compose_cmd() resolved (`docker compose` v2 vs `docker-compose`
v1) and therefore cannot go through run().

The 2 survivors are the genuine streaming Popen sites — the live container
log stream and the compose-up transcript. A captured call cannot do those.

run_checked gained two things this migration needed:

- `stderr` (the raw stream) as a separate key from `error` (the verdict, None
  on success). git and `docker exec` write to stderr and still succeed, so
  folding the stream into the verdict would invent failures. exec_command
  needs the stream; every other caller wants the verdict.
- merge_stderr=True, for `docker logs` and `compose logs`. A container writes
  its log to both streams; the `result.stdout + result.stderr` concatenation
  this replaces loses the interleaving, so a crash tail landed above the lines
  that led to it.

TIMEOUTS ARE UNCHANGED, deliberately. run_checked defaults to 60s and every
docker call in this file is unbounded today; inheriting the default would cap
`compose up --build`, `docker pull` and `docker build` at a minute and turn
working deploys into timeouts. Every migrated site keeps exactly what it has,
with timeout=None passed explicitly at the long ones and six tests pinning it
(mutation-checked: removing it from pull_image fails the test). Bounding
docker's long operations is a real improvement and a deliberate behaviour
change — its own commit, not a side effect of this one.

Two regressions caught by the suite and fixed here:

- Dropping the try/except around the compose-version probe let an exception
  escape into the caller, surfacing as `compose up` exit_code -1 instead of a
  v1 fallback. run_checked converts the exec failures it knows about, but this
  probe runs before everything else in the compose path and must not be able
  to raise at all. Guard restored, with a test.
- test_docker_protection hand-rolled a CompletedProcess stand-in with only
  returncode and stdout. Once list_containers went through run_checked,
  reading .stderr off it raised AttributeError, the caller's blanket except
  swallowed it, and the test saw an EMPTY CONTAINER LIST rather than an error.
  Retargeted onto the §G7 kit. A private stub is only correct until something
  reads the attribute it forgot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 13:45:25 -04:00
Juan DenisandClaude Opus 5 89481b15c0 refactor(system): run_checked() and a ratchet on the raw subprocess surface
Plan 75 §G1 — the door and the ratchet. The 33-file migration is NOT
done; see below.

run_checked(cmd, *, privileged=False, timeout=60, input=None). Around
backend/app's raw subprocess calls sit 220 copies of capture_output/text,
56 hand-rolled TimeoutExpired handlers, 19 FileNotFoundError handlers and
1,164 literal {'success': False, 'error': ...} dicts. Each is a place an
exec failure becomes a false fact.

It keeps the distinction those handlers lose: returncode is None when the
command never ran. Exit 1 means the command answered "no"; no exit code
at all means nobody answered. A caller rendering "not installed" can
finally tell them apart — plan 74's outage in one field. Unprivileged
calls get the same argv[0] resolution as privileged ones, because the
sbin outage is not something that only happens when you ask for root.

The ratchet is tests/raw_subprocess_census.py + RAW_SUBPROCESS_CEILING,
wired into the existing "Audit Raw Subprocess Patterns" CI job and
enforced by test_raw_subprocess_ratchet.py. The grep steps already there
check eight hand-listed files for three known-bad patterns, so a new file
was invisible to the audit entirely; the census counts every call site
under backend/app. It is AST-based because a grep for subprocess.run also
matches comments, docstrings and `except subprocess.SubprocessError`, and
a miscounting ratchet reads green either way. test_the_ceiling_is_not_stale
fails if the ceiling drifts more than 10 above reality — a stale ceiling
silently re-authorises everything a migration removed.

First migration: cron_service, 13 raw calls -> 3. Its `crontab -l` read
and Popen-with-stdin `crontab -` install were written out five times each
and are now two helpers over run_checked. Census 267 -> 258.

The other 32 files are untouched: docker_service (49), database_service
(24), git_service (18), security_service (14), python_service (13) are
the bulk. The door plus the ratchet are what make that incremental
instead of one unreviewable diff.

test_utils_system.py loads system.py under its own importlib spec, so
@patch('app.utils.system.<name>') binds to whichever module object is in
sys.modules at patch time — not necessarily the one run_checked closes
over. shutil/subprocess are singletons so the file's existing patches are
safe; these are the first tests to patch a module-level function of
system.py, and they use patch.object(_module, ...) instead.

BASELINE_COUNT 3383 -> 4081. Most of that is pre-existing drift (the
ratchet only fails on a drop, so an un-bumped floor never complains), not
tests added here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 12:05:17 -04:00
Juan DenisandClaude Opus 5 81208f2f68 fix(plugins): write manifests with LF instead of the platform newline
.gitattributes pins the whole repo to `text=auto eol=lf`, but plugin_service
wrote the tracked manifests through Python's text mode, which translates \n to
\r\n on Windows. Any install on a Windows dev box therefore rewrote
frontend/src/plugins/*/plugin.json with CRLF, and the backend suite -- which
installs builtin extensions -- left four modified manifests behind on every
run. The diff showed no content change, which made it look like unrelated
churn and made it easy to sweep the files into an unrelated commit.

This is the same failure the comment above the write already documents: the
trailing-newline half was found and fixed earlier, the line-ending half was
missed. Both halves now live in the same explanation.

_regenerate_frontend_manifest's write gets the same treatment for consistency,
though nothing covers it -- it is skipped under the testing config so it cannot
be reached from a test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 14:57:51 -04:00
Juan DenisandClaude Opus 5 f10c04ddc3 fix(notifications): redact transport errors before they reach the log
Review catch on #97. The chat connection test failure log interpolated
DeliveryResult.error verbatim. _deliver_webhook builds that from str(exc) on a
network error, and requests embeds the full URL in its exception text -- so the
line could carry a Discord/Slack/Teams webhook token or a Telegram bot token,
all of which live in the URL *path*. to_dict() masks `destination` even from an
admin, and /api/logs serves the log files back to the panel, so the log line
leaked past the API's own masking.

Dropping the reason would have restored the earlier problem of a failed test
button leaving no diagnostics anywhere, so it is redacted instead: URLs keep
scheme and host and lose the path, and bare bot tokens are substituted. The
host is what makes the line worth keeping and is not a secret.

Also normalise ChatWebhookConnection.raw_credentials() to {} when the stored
JSON is well-formed but not an object. A hand-edited or corrupt row could hold
`[]`, which every caller then treated as a mapping -- credentials() called
.items() on it, so to_dict() and therefore the list endpoint raised before the
write path was even reachable. Guarding at the source covers all callers and
matches the existing categories() pattern.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 14:36:24 -04:00
Juan DenisandClaude Opus 5 807f1f1674 test: ratchet 3262 -> 3379
The floor had drifted. It was last set to 3234 at 9fb0a6ca; the rounds since
then added tests without bumping it, so it sat ~117 below what CI actually
collected. The ratchet only fails on a *drop*, so the gap was silent -- and a
floor that far below reality stops catching the accidental deletions it exists
for.

Collection is deterministic between a dev box and CI here, so the local count
is the right value: every file under backend/tests/ is tracked (only
__pycache__ is ignored), and the one filesystem-globbing parametrization,
test_extension_manifests.py::_all_manifest_paths, walks builtin-extensions/
and frontend/src/plugins/ where all 16 manifests are tracked. Locally
installed extensions change which tests *skip*, never how many are collected.

Note when re-running this: check_test_count.py collects with a bare `pytest`,
while backend-ci.yml deliberately runs `pytest tests` because a bare collect
also walks locally deployed apps under backend/dev-data/. Both report 3379
today, but only because dev-data errors out of collection rather than adding
to it -- don't rely on that holding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 14:04:24 -04:00
Juan DenisandClaude Opus 5 5bbd4533f1 fix(notifications): scope chat credential re-encryption to supplied fields
Follow-up to PR #94. ChatWebhookService.update() decrypted every stored
credential and re-encrypted the lot on any credential-bearing request.
decrypt_secret_safe() returns its input unchanged when a value will not
decrypt, so a wrong-key or not-yet-migrated row got wrapped a second time
and became unrecoverable -- and it happened to fields the request never
named. It now patches the ciphertext dict directly and encrypts only what
it assigns, so untouched credentials survive byte-identical.

Also tolerate an unchanged is_default in a round-tripped body (to_dict()
always emits it, so GET -> edit -> PUT rejected an untouched field), and
record the delivery failure reason in the server log. The reason stays out
of the HTTP response: transport errors embed the full webhook URL and any
Telegram bot token.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 09:33:40 -04:00
Juan DenisandTyler Boykin 9fb0a6cab5 fix(security): close git argument injection in repo_url/app_path/branch (GHSA-8vx6-432p-h62q)
An authenticated user of any role could inject arguments into git
subprocesses running as the backend user (root under systemd):

- repo_url was passed unvalidated to git ls-remote/clone, enabling the
  ext:: transport (host command execution on git < 2.52 defaults) and
  file:// local-repository disclosure (all git versions).
- app_path on POST /deploy/clone was passed without a '--' terminator,
  enabling --upload-pack=<cmd> option injection independent of git
  version and protocol.*.allow policy.
- branch flowed into 'git fetch origin <branch>' as an unterminated
  refspec.

Fix (new app/utils/git_security.py applied at every affected sink):

- repo_url scheme allowlist (https/http/git/ssh + scp-like SSH);
  rejects ext::/file://, local paths, leading '-', whitespace.
- '-c protocol.ext.allow=never -c protocol.file.allow=never' pinned on
  every remote-touching git call, plus a restrictive GIT_ALLOW_PROTOCOL
  in the subprocess environment.
- '--' positional terminator before URL/path/refspec arguments —
  co-load-bearing with protocol pinning, which alone does not stop
  option injection such as --upload-pack=<cmd>.
- branch/ref validation (leading '-', ref-format metacharacters) for
  clone --branch, fetch refspecs and stored deploy configs.
- POST /deploy/clone now confines app_path to the managed apps root,
  mirroring how /apps/from-repository derives it server-side.

Adds 31 regression tests; bumps version to 1.7.86.

Reported-by: CaptBoykin
Co-authored-by: Tyler Boykin <25166954+CaptBoykin@users.noreply.github.com>
2026-08-11 19:43:46 -04:00
Juan DenisandClaude Opus 5 baff2452b0 test: guard the app fixture's scope so the suite can't quietly get slow again
Nothing catches a revert of `_flask_app` to scope='function'. The suite would
still pass — just ~3x slower — and CI would drift back to a 20-minute wait,
which is how it got that way in the first place. Same idea as the
collected-test-count ratchet: turn a silent regression into a red test.

Also asserts the inverse for `app`, which must stay function-scoped: it is what
resets the database between tests, and session-scoping it would leak each
test's rows into the next and fail while pointing at the wrong test.

Verified both directions — flipping _flask_app back to scope='function' fails
the guard with the explanation, and restoring it passes.

Ratchet 3173 -> 3175 for the two new tests.

Plan 64 Phase 4.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 03:01:24 -04:00
Juan DenisandClaude Opus 5 4ccfa7e8cf test: ratchet 3138 -> 3173
10 from the template repo/sync suite added alongside the DEFAULT_REPOS fix;
the rest accumulated since the last ratchet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 00:39:21 -04:00
Juan Denis e556baf238 test: ratchet 3117 -> 3138 (audit remediation proving tests) 2026-08-05 13:36:33 -04:00
Juan Denis d2d2d861cd test: ratchet 3213 -> 3117 (WP extension-owned suites moved out)
Collected 3142; floor keeps the usual ~25 headroom for
environment-dependent collection.
2026-08-05 02:38:54 -04:00
Juan Denis 0f8d179692 test: ratchet 3197 -> 3213 (wp hook inversion matrix) 2026-08-05 01:18:42 -04:00
Juan Denis 73de373b80 test: move minecraft suites to the extension repo, adjust ratchet
The four minecraft test files now live in serverkit-minecraft/tests/
(panel-hosted via SERVERKIT_MINECRAFT_DIR loader). Ratchet floor
3280 -> 3197 (-83 minecraft tests; collected 3222, floor intact).
2026-08-05 00:09:19 -04:00
Juan Denis d961bd23f0 fix(api): close IDOR/authz gaps across deployment jobs, vaults, queues, docker, databases
Proactive hardening from an internal audit of the same broken-authorization
class as GHSA-6w78-q5vm-rfmh (authentication checked, app/workspace-level
authorization skipped):

- deployment_jobs: job detail/logs/retry resolve the job and gate on
  can_access_app / can_operate_app (app-less jobs: requester or admin);
  global job listing is admin-only
- secrets_webhooks: every vault/secret/webhook-endpoint/delivery by-id
  route resolves to its workspace and 404-seals non-members; secret
  reveal and signing-secret regeneration require workspace owner/admin;
  dead local admin_required placeholder removed
- shared_resources: variable-group CRUD and attach/detach gate on the
  resolved scope (workspace role / can_edit_app / can_operate_app),
  closing deploy-time env injection into foreign apps
- queue_bus: message receive/complete/fail/list on system-owned groups
  are admin-only; user-owned groups restricted to their owner
- docker: raw container inspect (exposes env vars) and container logs
  are admin-only, matching Decision 7; per-app logs stay app-gated
- databases: raw SQL query endpoints, host-level metadata reads, and
  process lists are admin-only; per-app managed database flows unchanged

Adds 4 persona-matrix regression suites (74 tests) and bumps the
test-count ratchet.
2026-08-04 08:53:27 -04:00
Juan DenisandTyler Boykin 783e195304 fix(git): enforce app-level authorization on deployment read endpoints
GHSA-6w78-q5vm-rfmh: four git deployment/webhook read endpoints checked
authentication but skipped app-level authorization, letting any active
user (even a foreign viewer) read cross-tenant deployment history and
logs that can contain secrets from pre/post-deploy scripts.

- git.deployments/<id>: resolve deployment -> app, 404/403 via
  app_access_tier before returning any log bytes
- git.deployments/webhook/<id>: gate on _webhook_visible_to (app-linked
  webhooks follow app visibility; unlinked stay panel-wide, plan 29 #11)
- deploy.history & deploy.webhook-logs: app_id queries require
  app_access_tier; global (no app_id) queries are panel-admin only

Adds backend/tests/test_git_deployment_authz.py (9 persona-matrix
regression tests) and bumps the test-count ratchet.

Co-authored-by: Tyler Boykin <25166954+CaptBoykin@users.noreply.github.com>
2026-08-03 23:44:56 -04:00
Juan DenisandClaude Opus 4.8 3f3bff3132 test: bump BASELINE_COUNT ratchet 2507 -> 2534 for the batch's new suites
Locks in the proving tests added across plans 52/53/55 (runtime-frontend recovery
suites, db-snapshot relocation, generic fail2ban jail, gamekit framework, minecraft
install wiring, html-sink sweep) so they can't silently disappear.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 19:22:43 -04:00
Juan DenisandClaude Opus 4.8 54204d69c5 test(deploy): bump CI test-count ratchet to 2507 for the Deploy Console suites
The Deploy Console adds test_run_log_stream.py and test_deploy_console.py
(+77 collected tests). Full backend suite green: 2454 passed, 53 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 17:01:22 -04:00
Juan DenisandClaude Opus 4.8 6ac61a5e28 test: lower the test-count ratchet for the extension extraction
Extracting the seven builtins to their own repos took their suites with them —
2722 collected tests down to 2430. The ratchet fired exactly as designed, since
a 292-test drop is indistinguishable from the silent loss it was built to catch;
lowering the floor is the explicit statement that this removal was intentional.

Those tests are not gone, they moved: each extension's suite now lives in its
own repo and runs in that repo's CI. The four panel-core cases rescued from
test_tramo_extension.py stay here in test_tramo_panel_core.py.

This should have been part of the extraction commit, which is where the script's
own guidance points. It lands separately because the extraction was already
pushed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 01:56:17 -04:00
Juan DenisandClaude Opus 4.8 d1589024e3 test(analytics): bump test-count ratchet for the analytics extension
Full backend suite green (2669 passed, 53 skipped). serverkit-analytics adds 83
proving tests (+ 2 parametrized manifest-hygiene cases). Ratchet 2637 -> 2722.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 03:15:06 -04:00
Juan DenisandClaude Opus 4.8 b298c78d28 test(security): bump test-count ratchet for plan 48 proving tests
Raise BASELINE_COUNT 2623 -> 2637 for the two new files added by plan 48
(test_trusted_client_ip.py +7, test_login_bruteforce.py +6). Full suite:
2584 passed, 53 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 01:25:38 -04:00
Juan DenisandClaude Opus 4.8 176141295f docs(plan47): sync extension manifests, raise ratchet, changelog + docs
Plan 47 Phase 6 (for shipped work). Sync the pre-bundled frontend plugin.json
copies for the four extracted builtins (entry_point/url_prefix + status
description) so builtin-extensions/ and frontend/src/plugins/ agree
(sync-builtin-frontends --check + test_extension_manifests green). Raise the
test-count ratchet 2588 -> 2623. Document the second-speed backend-extraction
recipe in EXTENSIONS.md, add CHANGELOG entries (wizard installs + opt-in
FTP/cloud/remote-access/status), and a README "lean by default" bullet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 23:05:06 -04:00
Juan DenisandClaude Opus 4.8 4f448de660 docs(automations): changelog + raise test-count baseline to 2588
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 22:39:56 -04:00
Juan DenisandClaude Opus 4.8 5fc0b881b4 feat(connections): one-click GitHub setup via the GitHub App manifest flow
Connecting GitHub required hand-registering an OAuth app and pasting a client
id + secret — friction, and an open-source panel can't ship a secret anyway.
Now an admin sets it up in one click.

- backend: build_github_app_manifest / complete_github_app_manifest — hand
  GitHub an app manifest, convert the returned code into credentials stored
  locally (client id/secret in settings; PEM + webhook secret encrypted). No
  shared secret in the repo or build.
- connect flow: when an app slug exists, generate_github_authorize_url points at
  the app's install screen (grants repos + authorizes in one hop); repositories
  are listed per-installation since GitHub Apps don't use /user/repos.
- get_github_config surfaces provider_kind/app_slug/app_name/install_url.
- routes: GET/POST /source-connections/admin/github/app-manifest[/complete].
- frontend: Settings -> Connections leads with "Set up in one click" (auto-POSTs
  the manifest form to github.com); a new /connections/github-app/callback page
  finishes setup and chains into connect. Manual OAuth app moves under
  "Advanced". 7 proving tests (manifest shape, conversion+storage, install-url
  routing, app-mode repo listing); ratchet 2498->2505.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 23:06:09 -04:00
Juan DenisandClaude Opus 4.8 abe0fbece9 feat(templates): deploy templates as a backend repo-kind catalog entry
Deploy templates were a hardcoded frontend constant (AgentSite, with a
hand-fabricated manifest and an EXAMPLE badge). They now live in the backend
catalog as `kind: repo` YAML entries that blend with the 105 compose templates.

- template schema gains `kind: compose | repo` (absent = compose, so every
  existing template is untouched); repo templates carry a `repo` block with a
  `url` instead of a compose stack
- backend/templates/agentsite.yaml (kind: repo) replaces the JS constant
- list + detail payloads serve `kind`/`repo`; category derivation picks up
  repo templates automatically
- GET /api/v1/templates/<id>/manifest inspects a PUBLIC repo via
  RepositoryManifestService.analyze_public_repo (raw host, no clone, no OAuth)
  and falls back to the template's declared hints, labeled `template-hints`
- index.json + catalog-cert test recognize repo-kind; ratchet floor 2475->2498

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 02:38:46 -04:00
Juan DenisandClaude Opus 4.8 297c1e5864 ci(tests): add test-count ratchet + changelog recovery-parity (plan 42 ph6)
Install the guard the data loss taught us to want: code and its proving tests
died together, so the suite stayed green (2572->2209 passing) while whole
features silently vanished — nobody diffs a green number.

- backend/tests/BASELINE_COUNT (2475) + check_test_count.py: compares pytest's
  collected count to the floor; CI (backend-ci.yml new step) fails if it drops.
  Lowering the floor requires editing BASELINE_COUNT in the same commit, so a
  silent test loss surfaces as a red X instead of a still-green suite. Verified:
  passes at baseline, exits 1 on a simulated drop.
- CHANGELOG: document the recovery-parity restore (Cloudflare R2 engine,
  email-bounce, DNS-cutover staging, survey routes, cron CSS, 18 test suites)
  and the ratchet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 05:27:22 -04:00