Commit Graph
912 Commits
Author SHA1 Message Date
Juan Denis ff3c928917 feat(test): passive cgroup-v2 runtime baselines for full installs
Full VM installs and Test Sandbox full mode now record a short
post-install runtime observation (60s settle + 120s sampling) with an
explicit measured/unavailable/failed/interrupted status that never
rewrites install success. Adds the standalone scripts/measure-runtime.py
sampler, the measure-installed-runtime.py harness wrapper, E2E report
rendering, results-API summaries, CI coverage and METRICS.md accounting
boundaries.
2026-09-19 04:52:24 -04:00
Juan Denis 0aea7e3787 fix(plugins): hot-load extension blueprints on a live app (#144)
Flask refuses register_blueprint once the app has handled its first
request, so on any running panel — where login and dashboard requests
long precede the first extension install — the hot-load in
_register_plugin_blueprint always raised, the installer only logged a
'will load on restart' warning, and the extension's routes stayed
missing. Its pages rendered (frontend is static) while every API call
failed: GETs were swallowed by the tabs' console.error handlers, and the
first POST fell through to the static-file route, surfacing as the 405
reported for the Malware Scanner custom-path scan.

_register_blueprint_live waives Flask's setup guard for the duration of
the registration call. That is safe in this deployment shape: the panel
runs a single gunicorn worker with threads, so the process installing
the plugin is the process serving its routes, and werkzeug rebuilds its
URL matcher lazily on the next bind while in-flight requests keep the
matcher they already bound.
2026-09-18 01:28:59 -04:00
Juan Denis 8837a26af0 fix(logs): keep panel-internal roots out of the log viewer
PR #145 closed the prefix-sibling hole in LogService.is_path_allowed but
left the gap its author named: unlike FileService, the log viewer had no
PROTECTED_ROOTS exclusion. On the install.sh layout the install dir sits
under the allowed root /opt, so the Log Files tab could read — and,
through clear_log, truncate — the backend .env, the SQLite instance dir
and the generated deploy config, the same hole the file manager closed
for GHSA-rm3m-9mvw-68fh.

Share FileService.PROTECTED_ROOTS rather than copying the list, so the
two surfaces cannot drift apart, and reject protected paths before the
allowed-root check. Panel logs live under /var/log and SERVERKIT_DIR,
which are untouched, so legitimate viewing is unaffected.
2026-09-18 01:05:13 -04:00
Max Freedom Pollard 50d64e9d72 fix(logs): compare whole segments in path guard
LogService.is_path_allowed resolved the requested path with realpath and
then asked whether the result merely started with one of
ALLOWED_LOG_DIRECTORIES. That is a text comparison rather than a
containment test, so a directory whose name only begins with an allowed
root was accepted as if it sat inside it. With the roots shipped at
backend/app/services/log_service.py:30-36, both /opt-private and
/homestead pass on the strength of /opt and /home, which is not what the
comment above the list at line 29 says the list does.

FileService guards the same roots and gets this right at
backend/app/services/file_service.py:96-100, comparing real_path == root
or real_path.startswith(root + os.sep). The same form appears at
backend/app/api/deploy.py:197, backend/app/services/upload_service.py:66
and backend/app/services/linked_panel_agent.py:251. LogService does not,
and its guard also gates truncation through clear_log at
backend/app/services/log_service.py:367.

The loop now compares against the separator-terminated root, so a
sibling sharing a name prefix is rejected while everything genuinely
inside a root still resolves, including paths reached through .. because
realpath has already collapsed them. A regression test covering the
shipped /opt constant, a sandboxed root and the clear_log door ships
with it.
2026-09-15 19:39:37 -04:00
Juan Denis d866de5439 feat(connect): apply the managed presentation profile (plan 25)
Panel side of the managed profile: the panel receives a presentation
profile from Cloud over the existing command channel, stores it,
acknowledges it, and hides or simplifies fleet-owner-only surfaces
until the profile is lifted or the link is revoked.

- ui.managed_profile command handler with managed-profile.json
  storage, audit entries, and ack-by-output; profile is cleared on
  disconnect and on consent revoke
- GET /api/v1/connect/managed-profile for the local UI
- ManagedProfileContext with capability-driven filtering of sidebar
  items, command palette actions, and sidebar settings; ManagedCard
  explains the managed state
- In-page collapses for Templates deploy, About self-update, and
  Backup schedules/storage (restore stays available)
- Settings gains a ServerKit Cloud tab showing link and profile state

Local verification: 153 backend tests pass, 198 frontend node tests
pass, lint clean, build ok. No live policy has been pushed to a real
panel yet.
2026-09-13 03:50:02 -04:00
Juan DenisandClaude Fable 5.1 9e320586f3 test(templates): certify an external registry tree, and bring the changelog up to 1.11.4
The catalog certification test now reads SERVERKIT_TEMPLATES_DIR and
SERVERKIT_TEMPLATES_INDEX, so the official template registry's CI can
run the installer's own rules against its tree before a merge publishes
it to every panel. The build_repo_index() check stays bundled-only and
skips under the override.

The changelog had stopped at "1.11.1 - Unreleased" while 1.11.2 through
1.11.4 shipped: the Connect revocation, JWKS refetch, metrics-warning and
sidebar fixes are now recorded under the published 1.11.4 release.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JtgcyJ75YEozymcrWDyedu
2026-09-12 22:51:54 -04:00
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 fc5a041764 Fix first-run sidebar preferences and extension loading [skip version] 2026-09-11 00:17:39 -04:00
Juan Denis b181e32674 fix(connect): enforce consent and recover bounded log delivery [skip version] 2026-09-10 22:05:21 -04:00
Juan Denis b90931317c fix(ci): isolate dev release publication from PR builds [skip version] 2026-09-10 22:05:17 -04:00
Juan DenisandClaude Fable 5.1 3f60148214 feat(connect): publish inventory, diagnostics and bounded logs to Cloud [skip version]
connect_inventory builds inventory/1 (applications keyed on the row id so a
rename is not a new application; slug, branch and commit instead of a repo
URL; a WordPress admin URL only where this panel installed it; databases as
names, engines and states) and diagnostics/1 (busiest processes and mounts,
never a command line). connect_logs tails the panel and web error logs by
offset with a generation per rotation, reads application output and build
logs through the existing services, applies every Cloud bound locally,
redacts before sending, and stops on collect: false.

The relay client publishes inventory on connect and every five minutes,
diagnostics each minute while Cloud allows it with a fifteen-minute back-off
after a refusal, and logs every five seconds while sources are wanted.
Older Clouds keep answering the facts stream as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JtgcyJ75YEozymcrWDyedu
2026-09-10 21:35:18 -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 bebc69f71c feat(ai): connect task routing, guarded fallback, and scoped run accounting 2026-09-08 04:09:27 -04:00
Juan Denis 54028fbff4 fix(ai): enforce assistant enable state and conversation cost limits 2026-09-08 03:03:00 -04:00
Juan Denis fde0acee6d fix(ai): satisfy release gates and prepare 1.10.0 [skip version]
Keep connection persistence behind the service boundary, use typed errors and shared JSON parsing, and refresh reviewed inventories. The new GET connection catalog accepts API keys so API clients managing connections can discover IDs without exposing credentials; existing JWT-only routes stay unchanged. Add regression coverage and release notes.
2026-09-08 01:30:03 -04:00
Juan Denis add2f713a5 feat(ai): use Prompture 1.11 native gateway streaming 2026-09-08 00:47:58 -04:00
Juan Denis 569b052659 feat(ai): expose Prompture providers and saved connections 2026-09-08 00:42:54 -04:00
Juan DenisandClaude Fable 5.1 55291c3c10 fix(update): stop snapshotting the SQLite DB twice per update; test vendor shim MIME
The tree backup rsync copied backend/instance/serverkit.db even though the
same database had just been written as the pre-upgrade copy, and rollback
only ever restores from that copy. On a 450 MB database that made every
update cost ~900 MB of backups, which is how small VPSes kept filling up.
Exclude the SQLite files from the tree snapshot. Retention is unchanged
(SERVERKIT_BACKUP_RETENTION, default 3, plus the 15% disk budget).

Add a test that every shipped nginx vhost forces a JavaScript MIME type for
/serverkit-vendor/*.mjs and that the regex cannot capture the
/api/v1/plugins/<slug>/assets/... route.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NWc84WDsWR7M55Mn7qtpie
2026-09-08 00:27:42 -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 DenisandMax Freedom Pollard 32d8e76587 Merge PR #139: fix(bitbucket): send redirect_uri on the authorize hop
Co-authored-by: Max Freedom Pollard <272618364+MaxFreedomPollard@users.noreply.github.com>
2026-09-07 20:08:29 -04:00
Juan Denisandjrbertt 4f361fa3d1 Merge PR #136: fix(observability): resolve container runtime logs and metrics
Co-authored-by: jrbertt <joaorebertt0219@gmail.com>
2026-09-07 20:08:16 -04:00
Max Freedom Pollard bedf5640fd fix(bitbucket): send redirect_uri on the authorize hop
generate_bitbucket_authorize_url() takes a redirect_uri and leaves it
out of the query string, so Bitbucket returns the user to whatever
callback is registered on the OAuth consumer rather than the one the
panel asked for. complete_bitbucket_callback() then posts that same
redirect_uri to the token endpoint, so the two halves of one flow name
different callbacks.

Both sibling generators in the same file send it
(generate_github_authorize_url line 207, generate_gitlab_authorize_url
line 446), as does generate_auth_url in sso_service.py; Bitbucket was
the only one that dropped it. GET /source-connections/bitbucket/authorize
already rejects a request without redirect_uri, so the value was always
present and always discarded.

Adds the parameter, plus a regression test parametrised over all three
providers so the next one added is held to the same contract.
2026-09-07 06:12:48 -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 DenisandClaude Fable 5.1 01e0925c2a fix(auth): backfill users.auth_version so upgrades keep logins working
On an existing install the startup schema sync runs before alembic and
added users.auth_version as a bare nullable TEXT, so migration 097 saw the
column and skipped its '0' default. Every pre-existing user then carried
a NULL auth_version that no JWT claim could match: 2FA verification and
plain logins both failed with "Invalid or expired token" (reproduced on
the builditdesign test box after `update.sh --branch dev`).

- _fix_missing_columns now renders a literal server_default as a SQLite
  DEFAULT, so pre-alembic column adds backfill existing rows.
- migration 097 always backfills NULL/empty auth_version to '0'.
- tests pin both paths plus the default rendering.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVRnRXqNMZagqQ3RTqG5pz
2026-09-05 16:27:35 -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
jrbertt 81a56012c3 fix(observability): resolve container runtime logs and metrics 2026-09-05 08:44:39 -03:00
Juan Denis f490d75bee refactor: share backend lifecycle and fleet metrics operations 2026-09-05 05:06:41 -04:00
Juan Denis 6bf3490345 fix(ai): enforce resource access and bounded protected chat
Scope built-in tools to the live caller, recheck write authorization after confirmation, redact structured results, and fail closed when protections fail. Bound chat inputs, concurrent turns, and cancellation-aware streaming queues.
2026-09-05 04:29:45 -04:00
Juan Denis 785c5780d3 fix(sockets): authorize subscriptions and revalidate event delivery
Require valid access-token sessions, scope application and server rooms, and enforce run visibility consistently across sockets and polling. Add positive-delivery and cross-user isolation regressions.
2026-09-05 04:29:44 -04:00
Juan Denis c8c93a8e0c fix(auth): enforce MFA, scoped keys, and revocable sessions
Add session-family revocation and migration 097, require current credentials for password changes, constrain API keys, and require verified WebAuthn authentication. Update browser token handling and add boundary regressions. Existing JWTs require fresh login; restricted keys deny undeclared endpoints.
2026-09-05 04:29:16 -04:00
Juan DenisandClaude Fable 5.1 48899d5849 fix(demo-deploy): guard repo params; time-box the distro integration job
Copilot review on #135:
- `_repo_steps()` now falls back to port 3000 when `params.port` is not
  numeric instead of raising ValueError mid-stream.
- `params.pace` must be a positive number; zero, negative and non-numeric
  values are ignored so `line_delay` can never go negative.

CI: the ubuntu integration job sat in "Install Python & pytest" for ~2 h on
a wedged apt mirror, and Version Bump waits on every sibling. Cap the job at
15 minutes and give apt retries, a fetch timeout and non-interactive dpkg
conffile options.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVRnRXqNMZagqQ3RTqG5pz
2026-09-05 00:25:18 -04:00
Juan DenisandClaude Fable 5.1 3468afe48f test: read nginx configs as UTF-8 in CSP header test
The shipped nginx configs contain box-drawing characters; a bare
read_text() falls back to cp1252 on Windows and raises UnicodeDecodeError.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVRnRXqNMZagqQ3RTqG5pz
2026-09-04 22:21:04 -04:00
Rebertt RodriguesandJuan Denis a092ba1afe fix(dashboard): avoid content blockers on metrics history requests (#133)
Co-authored-by: Juan Denis <jhd3197@gmail.com>
2026-09-04 22:20:22 -04:00
Rebertt Rodrigues 498c249536 fix(github): repair app manifest setup flow (#131) 2026-09-04 22:20:16 -04:00
Juan DenisandClaude Fable 5.1 c2f34bf136 feat(deploy-console): parameterised repo simulated deployment
Add a `repo` scenario to the simulated deployments so a demo run reads like a
real deploy of a named service: clone, validate, build, route, start and
health-check lines carry the app's own name, repository, branch, port and
health path. The simulate endpoint also accepts a `title` (shown verbatim by
the Deploy Console) and `params.pace` to slow the stream for recordings.

Used by the walkthrough videos to show the Deploy Console streaming live
inside the read-only demo image, which has no git or docker.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SiGEPH8S231SMwFWTd76b6
2026-09-04 21:12:18 -04:00
Juan DenisandClaude Opus 5 eeeba465ba feat(connect): tell Cloud every id we hold for each agent
ServerKit Cloud counts a host once per organisation however many panels
report it, and it can only do that if it can recognise the same machine
twice. This panel was reporting each agent's own id and nothing else, so an
agent managed by two panels — or re-reported after a re-pair — was counted as
two servers and took two slots off the customer's plan.

The agent's machine id and key fingerprint are already on the enrolment row it
paired with, keyed by the server it was claimed onto. Both now go out with
every host entry. An agent with no enrolment row is still reported, with the
ids we have: a host left out of the list is a server Cloud cannot show at all.

Files that matter:
- backend/app/services/connect_client.py  _gather_hosts reads the enrolment
- backend/tests/test_connect_hosts.py

Verified: backend/venv/Scripts/python -m pytest tests/test_connect_hosts.py -q
→ 2 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013S1pQEJFU8P7US5MRpJFmi
2026-09-04 21:11:50 -04:00
Juan DenisandClaude Opus 5 1f92ae1c91 fix(connect): use the policy door, the privilege wrapper, and safe reads
Four things the guards and the review caught, all of them real:

- app/api/connect.py: GET /connect/status carried a bare @jwt_required(),
  which the jwt-only census forbids for a new route (609 vs a ceiling of
  608). It is an admin read of pairing state, so it takes @admin_required
  — the same door the rest of the admin surface uses, API-key capable by
  way of auth_required() like its neighbours.
- connect_policy._systemctl ran systemctl through raw subprocess, one over
  the raw-subprocess ceiling. run_privileged() adds sudo when the panel is
  not root, so the fail2ban repair now works off a packaged install
  instead of reporting a permission error nobody can act on.
- connect_client.hello_headers assumed the end-to-end key fields were
  always there. build_hello() drops them rather than fail the connection,
  so a panel that could not read its X25519 key would KeyError on the
  long-poll fallback — exactly the path taken when things are already bad.
- status() called resolve_cloud_url(), which raises on a URL with no
  scheme. A typo in SERVERKIT_CLOUD_URL should surface when you pair, not
  turn `serverkit connect status` and the Settings panel into an error.

Both connect threads are now classified in the thread-ownership registry:
the relay loop is a process loop, and a signed command is bounded delivery
whose record lives in the control plane.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018q56AfqpgKjio4n9KYubvZ
2026-09-03 00:19:27 -04:00
Juan DenisandClaude Opus 5 fd64ec09ba feat(connect): open commands sealed to this server's own key
A second keypair, X25519, published in the signed hello with an Ed25519
signature over it so ServerKit Cloud can tell it is really ours — and so a
relay cannot substitute one of its own, because it does not have this
panel's identity key.

Command arguments then arrive encrypted to that key. The relay carries a
blob. That is what makes it safe for Cloud to send an object-storage
credential through it, which until now crossed the relay in the clear.

A payload this panel cannot open is a refused command with a sentence
saying what to do, never a handler running on arguments we guessed at.

- backend/app/services/connect_keys.py — the key (a raw 32-byte file, not
  PEM: it is a key with no certificate story around it), the signed hello
  fields, and open_sealed.
- backend/app/services/connect_client.py — publishes it on WebSocket and
  on the long-poll fallback, so limited mode is not quietly less private.
- backend/app/services/connect_commands.py — unseal before the handler.
- backend/tests/test_connect_sealing.py — 13 tests, including the control plane's
  half of the scheme written independently from the specification, so the two
  implementations cannot drift apart silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018q56AfqpgKjio4n9KYubvZ
2026-09-02 17:00:00 -04:00
Juan DenisandClaude Opus 5 7ebc2aba34 feat(connect): report this server's policy facts to ServerKit Cloud
A facts document — firewall, SSH port, brute-force protection, the panel's
two-factor policy, its version, and when its backups last succeeded and were
verified — goes up the policy stream on connect and every six hours.
`policy.report` sends one now, which is what "Check now" presses.

The important part is what it does not claim. Each bundle is advertised in
`capabilities` only when its probe actually answered, so a rule Cloud cannot
get facts for reads "not checkable here" rather than marking a healthy
server non-compliant. Pending OS security updates have no probe here, so
that bundle is never advertised and its handler refuses honestly instead of
pretending.

Four repairs, each the panel's own path with its own refusals:
FirewallService.enable (which declines to lock SSH out), the require-2FA
policy with its grace window, systemctl start fail2ban, and the offsite
verification.

- backend/app/services/connect_policy.py
- backend/app/services/connect_commands.py — the three consent bundles
- backend/app/services/connect_client.py — the policy stream
- backend/tests/test_connect_policy.py — 17 tests, nothing touches the host

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018q56AfqpgKjio4n9KYubvZ
2026-09-02 16:00:04 -04:00
Juan DenisandClaude Opus 5 8fe469e1fe feat(connect): let ServerKit Cloud set where this server backs up
The panel can now run the signed commands Cloud sends, and the first four
of them are the Storage Hub's. A destination assigned in Cloud is tested
here before it is saved, written as `s3.path_prefix` (which every upload,
list and verify path already honoured), and reported back on a `storage`
stream. Unassigning hands it back and deletes nothing in the bucket.

- backend/app/services/connect_commands.py — verify a command JWT against
  ServerKit Cloud's JWKS (signature, audience, exp, nonce, consent scope), run the
  handler on its own thread, report running/succeeded/failed. An action
  this version does not know fails with a sentence saying so.
- backend/app/services/connect_storage.py — storage.assign/unassign/
  test/report, and the snapshot the storage stream carries.
- backend/app/services/connect_client.py — handle `command` opens, fetch
  the JWKS per connection, publish a storage snapshot on connect and
  every five minutes.
- backend/app/api/backups.py — GET /storage says when Cloud owns the
  destination; PUT answers 409 rather than letting two writers race.
- frontend/src/pages/Backups.jsx — the local form is read-only while
  Cloud manages it, with the sentence that says how to take it back.
- backend/tests/test_connect_storage.py — 23 tests, no network.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018q56AfqpgKjio4n9KYubvZ
2026-09-02 15:19:38 -04:00
Juan DenisandClaude Opus 5 bc10c51a16 feat(connect): keep the connect client current from the signed feed
On reconnect the client asks ServerKit Cloud what the current release is and
verifies the answer against the JWKS it already trusts before believing it —
the artefact lives on a CDN we do not control, so the signature is the only
reason to trust the URL in it.

- services/connect_updates.py  fetch, verify, compare, and act: an install
  the panel can update runs its own update path; one it cannot (Docker,
  non-root) says exactly what to run on the host instead
- services/connect_client.py   ask once per reconnect, at most hourly, and
  never let a failed check take the connection down with it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018q56AfqpgKjio4n9KYubvZ
2026-09-02 12:03:11 -04:00
Juan DenisandClaude Opus 5 1fbdddecd2 feat(connect): send ServerKit Cloud one metrics summary a minute
The panel already samples the host every 60 s into MetricsHistory for its own
charts. connect_metrics.MetricsPublisher turns the newest of those rows into
the summary Cloud expects, adds the counts Cloud alerts on (containers,
unhealthy apps, certificates near expiry, backup age) and hands it to the
relay socket on a `metrics` stream.

- services/connect_metrics.py  build, buffer (five minutes, then drop rather
  than send late), and apply the interval Cloud asks for
- services/connect_client.py   publish on the interval from the WS loop and
  read the stream close for Cloud's answer

Nothing new is collected: a minute the panel did not record is a minute Cloud
does not hear about. Every count source is optional — a panel without Docker,
domains or a backup policy sends null for those fields instead of failing the
whole summary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018q56AfqpgKjio4n9KYubvZ
2026-09-02 11:26:30 -04:00
Juan Denis 232c6978c5 feat(connect): hold a live outbound connection to the relay
- app/services/connect_client.py: RelayClient background thread (WS hello
  signed device_id|ts|nonce, 20s pings, jittered 1s->60s backoff with reset
  after 2 stable minutes, >6 reconnects/10min flap log, close-code ->
  state_reason mapping, terminal revoked, long-poll fallback with 15min WS
  re-probe); connect-state.json runtime state merged into status();
  disconnect() stops the client and removes the state file; run_doctor()
  implements the connect check table
- app/services/connect_keys.py: strict load_keypair() for the transport
- app/__init__.py: start_client_if_paired() next to the linked-panel hook
- cli.py: connect status shows transport/last-seen/relay-instance; new
  connect doctor command (exit 1 on hard-check failure)
- requirements.txt: websockets==15.0.1 (WS client; the SocketIO stack is
  server-side only)
- tests/test_connect_relay.py: 21 unit tests for backoff, flap detection,
  close-code mapping, hello signature, state file, loop transitions
2026-09-02 10:31:26 -04:00
Juan Denis 0a654e0f5f feat(connect): pair the panel with ServerKit Cloud from the CLI
- app/services/connect_keys.py: Ed25519 device keypair (0600, config dir),
  fingerprint = sha256(raw pubkey)[:16] shown in groups of four
- app/services/connect_client.py: enroll/poll (signed PoP)/rotate against
  the control plane, writes connect.json (0600) on claim;
  status()/disconnect(); control-plane URL from --cloud / SERVERKIT_CLOUD_URL / https://app.serverkit.ai
- cli.py: 'connect' group (pair, status, disconnect)
- app/api/connect.py + core_blueprints.py: GET /api/v1/connect/status (JWT)
- tests/test_core_blueprint_registry.py: registry guard constants for the
  new blueprint
2026-09-02 09:26:42 -04:00
Juan DenisandClaude Opus 5 aa9a570f57 fix(apps): keep compose the default; divert only build-pack apps
The previous commit asked "does a compose file exist on disk?" and sent
everything else down the container path. That was too wide in both directions:

  - test_start_app_authenticates_registry_before_compose_up seeds a
    registry-bound app whose compose file has not been rendered yet, and
    rightly expects `docker login` -> `compose up` -> `docker logout`. The
    disk probe diverted it and the start 400'd.
  - it also silently changed behaviour for any compose app whose file was
    missing for an unrelated reason, turning a clear compose error into a
    confusing "not been deployed yet".

Invert it. `_is_single_container_app()` is a positive test: a local app, no
compose file recorded, none on disk, and `buildpack_type` set -- i.e. one the
build pack deployed as a single container. Compose stays the default for
everything else, so no existing path changes.

Also seed the new test through `make_application` rather than constructing
Application(...) by hand, which is what test_seed_one_door_ratchet enforces
for files added after 2026-08-19.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 01:44:35 -04:00