Commit Graph
643 Commits
Author SHA1 Message Date
Juan DenisandClaude Opus 4.8 5e2d4afbda feat(wordpress): per-site security depth — integrity, debug, WP-Cron (#30)
A per-site Security tab (Docker-correct, all via the WP-CLI bridge):

- File integrity: a background wp core verify-checksums + wp plugin
  verify-checksums --all (in-container, reaches the volume; off the request
  thread, UI polls) flagging tampered/unexpected files.
- Debug toggle: WP_DEBUG/SCRIPT_DEBUG with WP_DEBUG_DISPLAY=false and
  WP_DEBUG_LOG=/tmp/wp-debug.log — OUTSIDE the web root, so the log is never
  publicly fetchable (the default wp-content/debug.log is apache-served, a real
  info leak this avoids). The write is gated so a read-only/stopped site reports
  an honest failure.
- WP-Cron: DISABLE_WP_CRON status + due-event list, run-due-now, enable/disable
  (with a clear "needs a real system cron" warning).

Routes mirror the per-site pattern (owner-or-admin GET, @admin_required
mutations). Deferred: the brute-force wp-login/xmlrpc rate-limit jail —
Fail2ban/nginx limit_req both need a host-side per-site access log the
localhost:PORT apache-container model does not expose (same gap as #21/#25).

Verified: backend py_compile, frontend lint + build, focused security review
(debug-log web-exposure caught + fixed). Not runtime-exercised on a real host.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 01:57:43 -04:00
Juan DenisandClaude Opus 4.8 2b5db90f89 feat(wordpress): per-site plugin/theme/core vulnerability scanning (#28)
Cross-reference a site's installed plugin/theme/core slug+version against the
keyless community WPVulnerability API and surface findings per site.

WpVulnerabilityService reads installed versions via the Docker-aware WP-CLI
bridge (wp core version + get_plugins/get_themes directly — not the
host-filesystem-gated get_wordpress_info, which fails for volume-backed Docker
sites), queries https://www.wpvulnerability.net/{plugin,theme,core}/ (no key,
1h in-memory cache, descriptive UA, 8s timeout), matches the installed version
against each advisory operator bounds (core advisories all apply — the feed is
version-scoped), and extracts CVE id / severity (cvss3 -> cvss short-code ->
score) / fixed-in / reference.

Persisted as a new WordPressVulnerability child model (FK -> wordpress_sites.id)
+ a last_vuln_scan_at column (Alembic 011, idempotent). The scan runs in a
background thread (Lynis-style) so the single worker never blocks; a new
Vulnerabilities tab polls for status and renders findings with severity badges,
CVE links, and a summary. On-demand only.

Security (3-lens review): reference_url restricted to http(s) (blocks
javascript: hrefs); feed slug percent-encoded; unrecognized version operator
skips rather than over-flags; the major Docker volume-backed core-read gating
was fixed so normal sites scan. Verified: backend py_compile, frontend lint +
build. Not runtime-exercised on a real host.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 01:45:20 -04:00
Juan DenisandClaude Opus 4.8 6949ceac06 feat(wordpress): selective-push — specific-table selectors in PromoteModal (#31)
PromoteModal already had files-only (code), db-only (database), and
specific-folders (include_plugins/themes/mu_plugins/uploads). The only missing
selective-push control was specific-tables, now added to the database section
as two comma-separated inputs:
- Exclude tables — omitted from the promotion entirely
- Truncate tables — structure promoted, rows dropped

Both split into arrays on submit. The backend already honored them end-to-end
(POST .../promote forwards config.exclude_tables/truncate_tables ->
promote_database/promote_full -> clone_options -> _transform_dump), so this is
pure UI wiring, no backend change. Verified: frontend lint + build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 01:21:51 -04:00
Juan DenisandClaude Opus 4.8 3dc1943da5 feat(ui): denser activity tab — inline stats + compact audit-log rows
Reworks the Settings > Activity tab away from stat-card tiles and icon'd
log entries toward a tighter, terminal-adjacent readout:
- Activity summary becomes a single inline stats line (active today /
  actions this week / total users) instead of three stat cards.
- Audit log entries render as compact log rows (role="list") with inline
  key=value details, dropping the per-row action icon and detail cards.
- _users.scss sheds the old card/icon styling for the leaner row layout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 01:15:32 -04:00
Juan DenisandClaude Opus 4.8 df047e45c3 fix(ui): brand logo renders blank — unique gradient id per instance
The ServerKitLogo SVG hardcoded id="skBrandGradient" for its fill gradient.
Two instances render at once (sidebar + mobile top bar), so the id collides
in the DOM. url(#skBrandGradient) resolves to the first match, which is the
mobile top bar's logo — hidden at 0x0 on desktop. A gradient with the
default objectBoundingBox units and a zero-size owner has no coordinate
space, so the fill painted nothing and the sidebar logo showed only its
white background square.

Generate a unique gradient id per instance with useId() so each logo binds
to its own gradient. The glyph now paints the --accent-primary master color
again across the sidebar, mobile bar, and auth screens.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 01:11:22 -04:00
Juan DenisandClaude Opus 4.8 484aeb575a feat(wordpress): per-site traffic + error analytics (#25)
A per-site Analytics tab showing traffic and error analytics, computed
on-demand from the site's Apache access log — Docker-correct.

Architectural pivot: managed WP sites are wordpress:*-apache containers on
localhost:PORT with NO per-site nginx, so the roadmap's "nginx access log"
premise does not hold. The official image symlinks Apache's access log to the
container's stdout, so `docker logs` is the source.

New WpAnalyticsService.get_traffic(container_name, hours) pulls
`docker logs --tail 20000 --since <h>` (hard 15s timeout; reads only stdout to
separate the access log from the stderr error_log; returncode-checked so a
stopped/missing/remote-agent container degrades to an accurate note) and parses
the Apache combined log on-demand — no store, collector, or migration. Returns
requests, unique visitors, bandwidth, status distribution (2xx/3xx/4xx/5xx),
404s, bot %, error rate, top URLs (grouped by route; query strings stripped so
visitor tokens are never surfaced), and a continuous hourly requests/errors
series.

Route GET /sites/<id>/analytics?hours= (owner/admin-guarded), client
getSiteAnalytics, and a new Analytics tab on the site detail (stat cards + a
recharts requests/errors AreaChart + status codes + Top URLs) with graceful
empty/timeout/unavailable states. Also supplies the 5xx/error-rate signal
#27's "error spike" rule needs.

Deferred (not in the default access log): PHP fatals (need wp-content/debug.log
via a WP_DEBUG toggle, #30), response time / slow pages (need a %D LogFormat),
cache hit ratio (#22/#23). History is point-in-time over the retained container
log; a persistent time-series collector is a clean follow-up.

Review fixes (3-lens): docker-logs timeout + memory cap (single-worker safety);
accurate unavailable note via returncode; query-string token stripping.
Verified: backend py_compile, frontend lint + build. Not runtime-exercised on a
real Docker host (Windows dev).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 01:10:04 -04:00
Juan DenisandClaude Opus 4.8 b0f3e8b9ef feat(ui): restore top padding on the Docker page
/docker is a FULL_PAGE_ROUTE, so .main-content drops its padding and the
page manages its own. The workspace shell and the "Docker Not Available"
state both relied on that missing padding, leaving their headers flush
against the viewport top.

- .docker-page (unavailable state): add the standard route padding
  ($space-10, $space-5 on mobile) so the header gets the same breathing
  room as every other page.
- .docker-page-new.dx-page (workspace shell): bump the top from $space-3
  to a $space-6 / $space-5 frame.

Verified in-browser on the unavailable state; build passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 00:57:47 -04:00
Juan DenisandClaude Opus 4.8 6ef998515d feat(wordpress): per-site uptime + auto-incidents (#26)
Make a managed WordPress site appear on a status page with a real uptime %
and auto-incidents, driven by an autonomous server-side health poller.

Scheduler: a single-worker-guarded daemon thread (_start_health_check_
scheduler, 300s) runs EnvironmentHealthService.check_health for every RUNNING
production site (skips archived/stopped so an intentional stop is not an
outage). This keeps health_status fresh autonomously — so #27's transition
alerts fire without the browser health card open — and drives bound
status-page components. HealthCheck samples are pruned to 90 days (once/day)
to bound growth.

Schema (Alembic 010 + the boot-time _fix_missing_columns auto-add):
- status_components.wordpress_site_id  (-> wordpress_sites)
- status_incidents.component_id        (-> status_components)
The existing HealthCheck table is reused as the uptime sample store.

StatusPageService.sync_component_from_health maps the health verdict
(healthy->operational, degraded->degraded, unhealthy->major_outage; unknown
skipped), records a HealthCheck, recomputes a real COUNT-based uptime %
(24h/7d/30d/90d; only fully-healthy checks count), and auto-opens an incident
on ENTERING a major outage / auto-resolves on LEAVING it (operational OR
degraded, so a degraded intermediate never strands the incident).

Routes GET/POST/DELETE /sites/<id>/status-page read the binding + attach
(production-only) / detach (resolves & unlinks the incident first -> no
dangling FK on PostgreSQL, no stale public incident). New Uptime tab on the
site detail (health + uptime % + attach/detach).

Security: internal probe config (check_target, intervals) is stripped from the
unauthenticated public-page projection, and a health-driven component stores
no internal localhost:port target.

Review fixes (3-lens adversarial pass): incident-resolve edge for the
degraded-recovery path; detach FK/orphan cleanup in delete_component; public
check_target leak; production-only attach; uptime-prune; degraded-uptime
semantics documented. Verified: backend py_compile, frontend lint + build.
Not runtime-exercised on a real Docker host (Windows dev).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 00:47:26 -04:00
Juan DenisandClaude Opus 4.8 2e6441e6de feat(ui): extend empty-state + AI-slop cleanup to remaining pages
Second pass, applying the same treatment as the infra pages to the rest of
the app. Migrate hand-rolled empty states across Applications,
ApplicationDetail, Backups, CronJobs, DNSZones, Email, Firewall, FTPServer,
SSLCertificates, Security, FileManager, Marketplace, Templates,
ServerTemplates, Workspaces, StatusPages, Monitoring, CloudProvision,
ServerDetail, ServiceDetail, WordPressProject, and WordPressProjects onto
the shared <EmptyState> component (icon + title + description + optional
action, plus its loading variant for "Loading..." placeholders). Inline
SVGs and ad-hoc divs are replaced with consistent lucide icons.

Strip hard-ban design tells found in the audit:
- Decorative gradients on icon/avatar backgrounds and hero/card surfaces
  (Marketplace hero + rainbow stripe, extension/featured/template cards,
  monitoring hero, status-page background, WordPress project icon) ->
  solid tokens.
- Side-stripe borders (>1px colored left-borders, a named ban) on Firewall/
  FTP status cards, Security event/findings items, status-page incident
  rows + public incidents, template variable items, WordPress env/pipeline
  cards -> full borders tinted with the same status color.
- Decorative backdrop-filter blur on an extension badge; kept it on real
  modal/drawer/drag backdrop overlays where it is purposeful.

Rich "feature not installed / unavailable" full-page states (with help
lists, resource requirements, troubleshooting) left as a deliberate tier.
Frontend build verified. WordPressDetail.jsx intentionally excluded here
(entangled with an in-progress feature on the working tree).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 00:46:30 -04:00
Juan DenisandClaude Opus 4.8 40e590f10c feat(ui): unify empty states + strip AI-slop on infra pages
Standardize empty states across Servers, Domains, Databases, Docker, and
Git onto the shared <EmptyState> component, matching WordPress/Services.
Replaces five divergent idioms (docker-empty, raw empty-state divs, inline
SVGs, servers-empty-workspace) so every "nothing here" layout reads the
same: icon + title + description + optional action.

Remove hard-ban design tells flagged in the audit:
- Decorative gradients on icons (WordPress site/detail icons, Databases
  hero + engine status icons) -> solid brand colors.
- Glassmorphism backdrop-filter blur on the Servers health panel and
  command bar (no-op over opaque surfaces).
- Side-stripe borders (>1px colored left-borders, a named ban) on Servers
  alerts/notifications, Docker container cards, and Git status/deployment
  cards + SSH note -> full borders tinted with the same status color.

No behavior changes; frontend build verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 00:32:21 -04:00
Juan DenisandClaude Opus 4.8 eca7cd2767 feat(wordpress): WP-aware alerting — site down/up → channels + events (#27)
Route WordPress health transitions to the configured notification channels
and the outbound webhook bus, edge-triggered so an autonomous poller never
spams.

EnvironmentHealthService.check_health now captures the prior health_status
and, ONLY on a state transition (up<->down edge), fires _notify_health_
transition -> _dispatch_health_alert which:
- preserves the existing WorkflowEventBus 'health_check_failed' emit
  (still fires every failing poll; back-compat for event-trigger workflows);
- NEW: NotificationService.send_all to every enabled channel
  (Discord/Slack/Telegram/email/generic). unhealthy->critical,
  degraded->warning (delivered by default), recovery->info (delivered only
  to channels that opt into info). Sent off-thread because send_all does
  blocking HTTP/SMTP and must not stall the health check;
- NEW: emits wordpress.site_down / wordpress.site_up via EventService, added
  to EVENT_CATALOG, so user webhook subscriptions (incl. wordpress.*) deliver
  with HMAC + retry and the events auto-appear in the subscription UI.

Edge-triggering is the dedup (no in-memory cooldown, which would be
single-worker-fragile): a continuously-down site alerts once, recovery once.
No schema/migration and no frontend — channel delivery is governed by the
existing Notifications settings. Also seeds #35's WP lifecycle-event registry.

Deferred: the "error spike" rule needs per-site error metrics that #25 will
provide. Verified: backend py_compile.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 00:26:57 -04:00
Juan DenisandClaude Opus 4.8 9bd757fa0f feat(ui): label connection status + fix status-badge dot, document text tokens
- Dashboard connection state is no longer color-alone: the green/amber dot
  now pairs with a text label (LIVE / RECONNECTING) in the server readout
  and is announced via role="status". Label uses a readable neutral
  (contrast-safe in both themes); the dot carries the status color.
  Removes the orphaned .status-dot-live rules.
- StatusBadge: destructive status dot was bg-white (off-pattern); now
  bg-red-400 to match the semantic hue of the other status dots.
- _variables.scss: document that the $text-*-raw values feed compile-time
  fade() washes (neutral zinc ramp) and are intentionally distinct from the
  lightened runtime --text-* tokens, so the split reads as deliberate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 00:12:23 -04:00
Juan DenisandClaude Opus 4.8 8bc5cc5512 feat(wordpress): one-click create wizard — PHP + page/object cache (#15)
Make WordPressService.create_site a single one-click orchestrator. It now
accepts php_version / enable_page_cache / enable_object_cache and:

- Bakes the chosen PHP version into the INITIAL image tag (concrete
  6.4-php<x.y>-apache) so the site boots on the right PHP with no
  post-create container recreate, then finalizes + _harden_docker_site,
  then best-effort enables page cache (#22) and Redis object cache (#23)
  AFTER `wp core install` (redis already ships in the stack). Cache
  enablement never fails the create — failures degrade to a non-fatal
  warning; the one-time admin password is still surfaced once.

- The live create modal (inline in WordPress.jsx) gains a PHP version
  selector (Default / 8.1 / 8.2 / 8.3) and page-cache / object-cache
  toggles, threaded through createForm -> POST /sites as phpVersion /
  enablePageCache / enableObjectCache (PHP validated server-side).

Mechanism: declare a hidden VERSION template var and switch the image
line to bare ${VERSION} in wordpress.yaml + wordpress-external-db.yaml so
a concrete tag is written at install. This also fixes a latent #24 bug
where the unresolved ${VERSION:-...} literal made set_php_version drop the
WP-core pin; set_php_version now also falls back to the known core for
legacy compose files. No DB migration — PHP/object-cache/SSL read live;
the page-cache flag rides the existing sync_config column.

Deferred: SSL/TLS + custom-domain at create. Managed sites are
http://localhost:PORT containers with no per-site reverse proxy and no
domain attached at create — the same missing public-domain/routing infra
that blocks #21 and that #8 left frontend-only. TLS remains operable
post-create via SiteSSLPanel (#8) once a domain is routed.

Verified: backend py_compile, frontend lint-clean + production build, a
3-lens adversarial review (no blockers), and Docker Hub confirms all
6.4-php8.{1,2,3}-apache tags exist. Not runtime-exercised on a real
Docker host (Windows dev).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 00:12:23 -04:00
Juan DenisandClaude Opus 4.8 768100462c feat(ui): keyboard a11y hardening + quieter brand chrome
Two refinement passes on the dashboard shell, committed together
because their edits interleave in Sidebar.jsx and _sidebar.scss.

Accessibility hardening:
- App-wide focus-visible baseline in _reset.scss, replacing the blanket
  `* { outline: none }` that suppressed all focus rings. Pointer focus
  stays ringless; keyboard focus gets a consistent accent ring.
- User-menu trigger is now a real <button> (aria-haspopup/expanded,
  aria-controls, Escape-to-close with focus return).
- Nav expand buttons get aria-expanded + labels; theme/view buttons get
  aria-pressed + accessible names; menu items get type="button".
- Dashboard app rows expose a real <Link> for keyboard/screen-reader
  navigation (row stays mouse-clickable).
- Decorative icons marked aria-hidden; per-component focus rings on nav
  items, menu items, and dashboard actions.

Quieter brand chrome:
- Removed gradient-text from the wordmark and white-label text; now
  solid $text-primary (drops the background-clip:text ban).
- Replaced the auto-firing, bounce-eased star celebration (particle
  burst, rings, shine sweep, bg flash) with a calm hover affordance;
  deleted the animation effect/state and 8 dead keyframes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 23:48:55 -04:00
Juan DenisandClaude Opus 4.8 1080d31118 feat(wordpress): per-site PHP version + limits panel (#24)
Phase C #24. The roadmap referenced the host PHP-FPM API, but managed sites are
wordpress:*-apache containers (PHP baked into the image tag), so this is done the
Docker-correct way via the wp_cli bridge.

New "PHP" tab on the site detail page showing the LIVE PHP version and key ini
limits (memory_limit, upload_max_filesize, post_max_size, max_execution_time,
max_input_time) read from inside the running container via `wp eval` (read-only,
zero risk). A "Change PHP Version" panel switches PHP by rewriting the compose
image tag to wordpress:<core>-php<x.y>-apache and recreating the container
(DB/files preserved, brief downtime) behind a confirm; supported set 8.1/8.2/8.3.

New routes GET/POST /sites/<id>/php; getPhpInfo/setPhpVersion client methods. The
panel reuses existing app-panel/app-info-grid markup — no new SCSS (no conflict
with the concurrent style session). Application.php_version is left untouched (not
meaningful for Docker sites); the live container value is the source of truth.

Writing arbitrary PHP limits is explicitly deferred (needs a conf.d ini + a
compose bind-mount for durability across recreates).

Verified: backend boots, both /php routes register, service methods load;
frontend builds; changed JS lints with 0 errors. No DB migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 23:38:15 -04:00
Juan DenisandClaude Opus 4.8 6b67271677 feat(wordpress): page cache + per-site Redis object cache (Docker-correct)
Phase C batch 1 — roadmap #22 and #23. The roadmap assumed nginx fastcgi/proxy
cache + host php-fpm, but managed sites are self-contained wordpress:*-apache
containers on localhost:port with no nginx in front — so both are implemented
the Docker-correct way, entirely in-container via the wp_cli bridge.

#22 Full-page cache via the cache-enabler plugin (Apache disk cache), installed
+ activated through wp_cli with WP-aware skip rules (wp-admin/login, logged-in
+ cart/checkout/account cookies, query strings). New routes GET/POST/DELETE
/sites/<id>/page-cache; the enable flag is stored in WordPressSite.sync_config
(no migration); the existing Purge Cache button now also purges the page cache.

#23 Per-site Redis object cache. enable_object_cache ensures a redis service in
the site's compose stack (NEW sites ship redis via the template; EXISTING sites
get a redis service injected into docker-compose.yml + an additive `compose up
-d` with no downtime), then installs the redis-cache plugin (bundled Predis, no
phpredis needed) and points WP at host 'redis'. Status derived live from
`wp redis status`. New routes GET/POST/DELETE /sites/<id>/object-cache; Purge
Cache now also runs `wp redis flush`.

UI: "Enable Page Cache" and "Enable Object Cache" toggles in the site Quick
Actions, reusing the existing .quick-action-btn class (no new/shared SCSS).

Verified: backend boots, all 6 routes register, all 8 service methods load,
template YAML valid; frontend builds; changed JS lints with 0 errors. No DB
migration. (Container-level behavior not runtime-tested — no Docker here.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 23:30:17 -04:00
Juan DenisandClaude Opus 4.8 61b576952f feat(ui): respect prefers-reduced-motion across the app
The app had no reduced-motion handling despite ~60 keyframe animations
and pervasive transitions (auto-firing star burst, etc.). Honor the OS
"reduce motion" setting globally while keeping loading state legible.

- Global @media (prefers-reduced-motion: reduce) block collapses
  transitions and decorative animations to ~instant
- Loading spinners (.spinner-ring, [class*="spinner"]) stay turning at a
  steady 0.8s infinite rotation, since the spin IS the status cue
- Gate the sidebar star's auto-firing celebration behind a reactive
  useMediaQuery('(prefers-reduced-motion: reduce)') check
- Add $ease-out-quart/quint/expo easing tokens (no bounce/elastic) and
  point the mobile drawer transition at $ease-out-quint

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 23:26:54 -04:00
Juan Denis ea61b4409d Update .gitignore 2026-05-31 23:18:58 -04:00
Juan DenisandClaude Opus 4.8 f14f9b947f docs: add PRODUCT.md design context (impeccable)
Strategic design context for the impeccable workflow: product register,
users/purpose, "precise, trustworthy, controlled" personality,
anti-references, design principles, and WCAG 2.1 AA targets.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 23:18:23 -04:00
Juan DenisandClaude Opus 4.8 6172ffe0b9 feat(ui): add mobile navigation drawer for narrow viewports
Below 768px the persistent sidebar was set to display:none with no
replacement, leaving every route unreachable on phones/tablets. It now
collapses into an off-canvas drawer driven by a fixed mobile top bar.

- MobileTopBar: fixed header with hamburger toggle + brand (white-label
  aware, solid wordmark), shown only < 768px
- Sidebar becomes a translateX drawer at min(280px, 86vw); focus trap,
  Escape-to-close, inert when closed (React 18 safe), 44px close button,
  aria-label + id wired to the toggle's aria-controls
- DashboardLayout owns open state via useMediaQuery, closes on route
  change, locks body scroll, renders the scrim
- Semantic z-index ladder (scrim/drawer/header), 100dvh + safe-area
  insets, viewport-fit=cover, prefers-reduced-motion fallback
- Main content offset below the fixed header for normal and full-page
  routes

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 23:18:18 -04:00
Juan DenisandClaude Opus 4.8 1f3ff4fcaf feat(wordpress): existing-site import (.sql) and clone-to-new-independent-site
Phase B batch 2 — roadmap #17 and #18 (#21 deferred: blocked without a public base domain).

#17 Existing-site import (.sql-first MVP). New "Import Site" action on the
WordPress hub opens a modal (name + original URL + .sql/.sql.gz dump). The
backend POST /sites/import stands up a fresh WordPress+MySQL Docker stack via
create_site, streams the dump into the container DB via
DatabaseSyncService.import_to_container (root user, gzip-aware), rewrites the URL
(wp option update home/siteurl + wp search-replace --all-tables), flushes caches,
re-detects multisite, and clears the stale generated admin. wp-content-zip and
SFTP pull are explicitly out of scope for the MVP.

#18 Clone a site to a NEW independent top-level site with FRESH credentials. New
WordPressService.clone_site stands up a brand-new stack via create_site,
container-to-container clones the source DB with a URL search-replace, best-effort
copies wp-content via docker cp, then provisions a fresh administrator (new
generated password) so the clone does not share the source's creds (the DB import
overwrites users). POST /sites/<id>/clone + a "Clone" button in the site topbar +
a one-time credentials banner (with an "Open new site" link). No production_site_id
=> truly independent.

Both reuse the existing create/clone/wp-cli primitives; DB creds read from each
stack's .env (root user, literal db name 'wordpress' since the columns are NULL
for Docker sites). FormData upload uses the api client's existing multipart path.

Verified: backend boots, both routes register, all service methods load; frontend
builds; changed JS lints with 0 errors. No DB migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 23:09:02 -04:00
Juan DenisandClaude Opus 4.8 62adcdf28d feat(wordpress): passwordless WP-admin login, multisite detection, site tags
Phase B batch 1 — roadmap #16, #19, #20.

#16 Passwordless "Auto Login" into wp-admin (the signature managed-WP feature),
no heavy plugin. New WordPressService.create_login_url uses the Docker-aware
wp_cli bridge to install the wp-cli-login package (idempotent) and mint a
one-time magic URL (wp login create --url-only); login slug resolved via
wp eval rather than hardcoding /wp-admin. New POST /sites/<id>/login
(admin-gated) resolves or provisions a managed admin tied to the operator's
panel email (create_user role=administrator), persists admin_user, writes a
'wordpress.admin_login' AuditLog, and returns the URL. A prominent primary
"Auto Login" button in the site topbar opens it; Dashboard remains as fallback.

#19 Multisite is now detected for real via `wp core is-multisite` (new
is_multisite helper): surfaced in get_wordpress_info, set at create_site, and
refreshed+persisted on the single-site detail load (never on the hot list
endpoint).

#20 Site tags/labels for agency organization. New JSON-in-TEXT tags column on
WordPressSite (auto-added on boot by _fix_missing_columns; plus an idempotent
009 Alembic revision for determinism), exposed via to_dict, set via
PATCH /sites/<id>/tags (normalized/deduped). Frontend: tag chips on each site
card and a client-side tag filter bar on the WordPress list.

Verified: backend boots (009 migration applies, tags column live, routes +
methods present); frontend builds; changed JS lints with 0 errors. No frontend
or backend regressions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:54:06 -04:00
Juan DenisandClaude Opus 4.8 4393a47512 feat(wordpress): finish Phase-A glue — create hardening, real URLs, offsite snapshots, push-to-deploy, API keys
Completes roadmap #2, #3, #11, #13, #14.

#2 Canonical Docker create now finalizes the install. create_site waits for the
container to be ready, runs `wp core install` (creates a real admin user) and
applies container-valid hardening via the Docker-aware wp_cli bridge
(DISALLOW_FILE_EDIT, XMLRPC_REQUEST off, shuffle-salts) — NOT host-filesystem
ops, which don't apply to the named-volume model. The generated admin password
is returned and surfaced once in the create page (so the new admin is usable).
Best-effort: site creation still succeeds if the container is slow to finalize.

#3 _enrich_site_data derives the site URL from the primary domain (https when
ssl_enabled, else http), falling back to http://localhost:<port> only when no
domain is attached. Fixes Open Site / Open WP Admin / the SSL panel.

#11 Offsite snapshots + retention. New DatabaseSyncService.upload_snapshot_offsite
(best-effort S3/B2 upload, no-op unless a remote provider + auto_upload are
configured) is called after every snapshot is created (manual + pre-promotion).
New prune_expired_snapshots backfills expires_at (default 30d, tagged snapshots
kept) and deletes expired file+row; an hourly 'snapshot-retention' scheduler
thread runs it. No migration (expires_at/status columns already exist).

#13 Push-to-deploy for WordPress. WebhookService._handle_push_event now fans out
to every WordPressSite whose connected repo+branch matches the push and has
auto_deploy=true, calling GitWordPressService.deploy_from_commit. Repo URLs are
matched scheme/.git/creds-agnostically via a new _normalize_repo_url helper.
Best-effort; never breaks the generic-app webhook path.

#14 WordPress REST is now API-key reachable. All 12 wordpress_sites.py routes
switched from bare @jwt_required() to @auth_required() (accepts API key or JWT),
reading the user via get_current_user().id instead of get_jwt_identity()
(which returns None under an API key). Ownership filters unchanged.

Verified: all backend files compile; the app boots and the retention scheduler,
route swaps, and helpers load; _normalize_repo_url maps ssh/https/creds forms
identically; frontend builds; WordPress.jsx lints clean. No DB migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:39:57 -04:00
Juan Denis 0738902dc9 Merge branch 'dev' of https://github.com/jhd3197/ServerKit into dev 2026-05-31 22:22:41 -04:00
Juan DenisandClaude Opus 4.8 4f79e04645 feat(wordpress): health card, update manager, SSL panel, and archive/delete/rollback UI
Implements roadmap #6, #7, #8 and surfaces the #4/#10 backends in the UI.

#6 Consolidated Site Health card in the WordPressDetail overview: loads
getProjectHealth + getProjectDiskUsage on mount and shows overall status,
WP/PHP version, container/database/HTTP checks (HealthDot) and the previously
orphaned DiskUsageBar — with loading/error/standalone-None handled gracefully.

#7 WordPress update manager. New update_themes service method + POST
/sites/<id>/themes/update route (registered before /themes/<theme>/activate),
and getWordPressInfo/updatePlugins/updateThemes ApiService methods. OverviewTab
shows a core "Update available" badge + Update button; PluginsTab and ThemesTab
show per-row update badges/buttons (keyed off the WP-CLI `update` field) plus a
bulk "Update all" bar. All update state is computed live via WP-CLI.

#8 Per-site SSL panel. Shows the primary domain and live certificate status
(via GET /ssl/advanced/health/<domain>) and an "Enable SSL" action that issues a
Let's Encrypt cert through the existing /ssl/certificates route. SSL status is
derived live (no DB column / migration); localhost / private-IP / no-domain
sites degrade to an explanatory hint instead of a dead button.

#4 UI: a Danger Zone with Archive/Unarchive (toggles on site.status) and a
Delete Site modal (typed-name confirmation + create-backup checkbox) wired to the
existing archive/unarchive/delete endpoints.

#10 UI: a Promotion History list in the pipeline tab (self-fetched) with a
Rollback button shown only for completed promotions that have a pre-promotion
snapshot, wired to rollbackPromotion via the existing ConfirmDialog.

SCSS added for update badges/bulk bar, health/SSL helpers, and promotion history.

Verified: frontend build passes; backend boots and the themes/update route +
update_themes method register; changed JS files add 0 new lint errors. No DB
migration introduced.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:04:39 -04:00
Juan DenisandClaude Opus 4.8 dc54273ef0 feat(wordpress): Phase-A glue — WP-CLI actions UI, sanitization wiring, promotion rollback, deep-links
Implements roadmap tasks #5, #9, #10, #12 — wiring existing primitives into the
WordPress site object.

#5 Surface orphaned WP-CLI maintenance actions. Adds flushCache/searchReplace/
harden methods to the WordPress ApiService and Quick Actions UI in
WordPressDetail (Purge Cache, Search & Replace modal with dry-run, Harden behind
a confirm). The backend routes already existed and had no UI.

#9 Stop dropping the sanitization profile. promote_database and
sync_from_production now resolve the selected sanitization_profile_id (via a new
_apply_sanitization_profile_options helper) and merge its rules — anonymize,
table truncation/exclusion, custom search-replace — into the clone options,
instead of honoring only the boolean `sanitize`. User-scoped; composes with the
existing flag; no-ops when no profile is selected.

#10 Close the promotion rollback loop. Adds
EnvironmentPipelineService.rollback_promotion (restores a promotion's
pre_promotion_snapshot into the target environment — container import or host
restore — sets status='rolled_back', locks/unlocks, logs activity) and the route
POST /wordpress/projects/<prod>/promotions/<id>/rollback, plus a
rollbackPromotion ApiService method. Pre-promotion snapshots were captured but
never restorable.

#12 Deep-link DB/Files/Logs from a site. Adds Open Files (File Manager at the
site root), Open Database (DB manager pre-scoped via ?db=, hidden when db_name is
null), and View Logs (global LogsDrawer for the container) to the WordPressDetail
topbar. Databases.jsx MySQLTab now honors a ?db= query param to auto-open the
QueryRunner.

Verified: backend boots and the rollback route registers; frontend builds; the
three changed frontend files lint with 0 errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:37:38 -04:00
Juan DenisandClaude Opus 4.8 cf31b2658e fix(wordpress): resolve /sites blueprint collision; backup-before-delete + archive
#1 Collapse the duplicate WordPress hub routes. Both wordpress_bp and
wordpress_sites_bp were mounted at /api/v1/wordpress and both defined
GET/POST /sites, /sites/<id>, environments, plugins, themes and update, so the
second blueprint's handlers were silently shadowed. wordpress_bp (the Docker
stack model the live UI creates against with {name, adminEmail}) now solely
owns the hub surface; wordpress_sites_bp keeps only its unique routes (sync,
snapshots, clone-db, git). Verified 0 path+method collisions in the live URL map.

#4 Backup-before-delete + archive. delete_site(create_backup=True) now captures
a final files+DB backup via the Docker-aware backup_wordpress() (written to
BACKUP_DIR, outside the site root, so it survives rmtree) before teardown, and
returns the backup info. Added reversible archive_site/unarchive_site plus the
POST /sites/<id>/archive and /unarchive routes, and a ?create_backup=false
opt-out on delete. Frontend gains archiveSite/unarchiveSite/deleteSite(opts).
Also fixed a latent bug: _teardown_wp_site called compose_down(remove_volumes=)
but the real parameter is volumes=, which would have raised TypeError.

Also adds docs/WORDPRESS_ROADMAP.md (35-task managed-WordPress roadmap).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:19:58 -04:00
Juan Denis 562254fe06 Improve create-pr skill guidelines
Clarify and expand the create-pr skill instructions: require conventional-commit style for PR titles (type(scope): summary), pick the dominant type, use lowercase imperative summaries, and note CI/version-bump implications. Increase the recommended Summary from 2-3 to 4-6 sentences and add detailed guidance on tone, context, reasoning, and what to include (and avoid). Add a final note that the skill should stop after writing the .pr file (do not run gh/git push); print the file path and a one-line readiness note.
2026-05-31 20:34:53 -04:00
github-actions[bot] 29284d50dc chore: bump version to 1.6.8 [skip ci] 2026-06-01 00:22:14 +00:00
Juan DenisandClaude Opus 4.8 9e511caba7 fix(deps): bump Authlib 1.6.11 -> 1.6.12 (CVE-2026-44681)
Safety flagged Authlib 1.6.11 for CVE-2026-44681 (MEDIUM): an open-redirect
via insufficient redirect_uri validation in the OAuth flow — directly
relevant since Authlib backs the SSO/OAuth login. 1.6.12 is the upstream fix
(patch bump, same 1.6.x line, lowest risk to the SSO code).

Authlib was pinned twice in requirements.txt; bumped both so they don't drift
into conflicting exact pins.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:06:58 -04:00
Juan DenisandClaude Opus 4.8 c062a3708d ci: restore version-bump guard/summary; bump on tags OR prefixes
Follow-up to the injection fix (0f2299f), which I'd over-trimmed. Restores
original behavior with the safety fix kept:

- Re-add the if: guard so the job skips [skip ci]/[skip version] and its own
  'chore: bump version' commits, so the bot's bump commit can't re-trigger
  the workflow.
- Re-add the Create summary step (reads the message via env TRIGGER_MSG and
  takes only the first line, so a multi-line/special-char body can't corrupt
  the markdown table or the script).
- Bump detection matches a [major]/[minor] tag OR the original conventional
  prefixes (BREAKING/major:, feat/feature/minor:), so existing feat:/BREAKING:
  commits keep bumping minor/major as before.
- Keep the original dev-only trigger and paths-ignore.

The message reaches both steps via env (COMMIT_MSG / TRIGGER_MSG), never
spliced into run-script source. Verified: YAML parses and bump detection
returns the right type for feat:/BREAKING:/[minor]/[major]/plain messages,
including ones containing backticks, pipes and $(...).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:01:20 -04:00
Juan DenisandClaude Opus 4.8 0f2299f60d ci: fix version-bump shell injection on commit message + drop stray fences
The bump-version job did `COMMIT_MSG="${{ github.event.head_commit.message }}"`
inside a run: block. GitHub Actions text-substitutes ${{ }} into the script
source BEFORE bash runs, so a commit body containing backticks, $(...) or |
is spliced in raw — breaking the step (and an arbitrary-command-injection
vector). Commit e3337f0's body contained `vagrant up | Out-Host`, so the
backticks opened a command substitution and the pipe was a syntax error:
"syntax error near unexpected token `|`" — the reported CI failure.

Pass the message via env: COMMIT_MSG instead, so it reaches the step as an
inert environment variable that bash never re-parses. Verified by simulation:
the old form reproduces the exact error; the new form handles backticks,
pipes and $(...) safely and still detects [major]/[minor] markers.

Also removes three stray ``` markdown code fences (lines 64/70/71) that had
been pasted into the YAML, and the dead BACKEND_DIR var + placeholder
comment. YAML validated with a parser.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 19:55:26 -04:00
Juan DenisandClaude Opus 4.8 e3bb163221 test: harden Vagrant/RHEL E2E harness; add vm-install overlay script
- Vagrantfile: don't `dnf install rsync/curl` on RHEL boxes — it upgrades
  openssl-libs and breaks the box's sshd (same ABI issue as install.sh).
  rsync/curl ship preinstalled on generic/rocky9; fail loudly if a future
  box lacks them rather than silently corrupting the run.
- full-stack-test.ps1: drop fedora from the default distro set (Vagrant
  Cloud's generic/fedora is EOL at f39; opt in via -Only fedora).
- vm-install.sh (new): run install.sh from uploaded local source, overlay
  the working tree, rebuild, and probe health — so the harness tests the
  exact local code (incl. uncommitted) with sshd/firewall snapshots on every
  exit path for post-mortems. Recovered from a local stash.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 19:45:09 -04:00
Juan DenisandClaude Opus 4.8 28cdf2280f fix(agent): skip post-pair service restart in standalone (non-MSI) mode
After pairing, the Windows pairer always ran sc stop/start on
ServerKitAgent. On standalone exe installs (no MSI) that service doesn't
exist, so sc failed with error 1060 and surfaced as a fake "pairing failed"
even though credentials were written fine. Add isServiceInstalled() (sc
query; 1060 => not installed) and skip the stop/start dance cleanly when the
service isn't registered, in both the pair-code and connection-string flows.
Also adds the missing waitForServiceStopped() before start in the
connection-string flow (avoids 1056 during STOP_PENDING). go build ./... +
vet green (windows). Recovered from a local stash.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 19:45:04 -04:00
Juan DenisandClaude Opus 4.8 6e0db86c69 fix(install): pre-upgrade openssh+openssl together on RHEL family
On Rocky/RHEL 9.7+ the updates stream ships openssl-libs 3.5.x with a
different ABI than the 3.0.x most boxes ship with. If a later dnf install
pulls the new openssl-libs as a transitive dep, every new sshd fork dies
with "OpenSSL version mismatch" — TCP accepts then RSTs and remote installs
over SSH fail silently. Replace the blanket `dnf update -y` with
`dnf makecache --refresh` plus an upfront `dnf upgrade` of openssh*+openssl*
in one transaction so sshd restarts against a matched pair (sshd
KillMode=process keeps the live session alive). Recovered from a local
stash; this is the documented Rocky9 openssl fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 19:44:59 -04:00
Juan DenisandClaude Opus 4.8 7c0a6b6c87 fix(frontend): dedupe React so plugins don't crash on a second copy
Plugin code loaded via import.meta.glob could resolve a different React
instance than the host if Vite's dep optimizer ran mid-session, producing
"Invalid hook call / ReactCurrentDispatcher is null" inside the
contributions hook. Add resolve.dedupe for react/react-dom/react-router-dom
and pre-bundle them via optimizeDeps.include so the renderer is fixed at
server start instead of lazily re-bundled (which stranded the open tab on a
stale React URL). Recovered from a local stash.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 19:44:55 -04:00
Juan DenisandClaude Opus 4.8 6525e66a83 fix(backend): auto-allow SERVERKIT_PUBLIC_URL in CORS; bump Flask-SocketIO
When the panel sits behind a reverse proxy / tunnel, browsers and agents
arrive from SERVERKIT_PUBLIC_URL. Without that origin in CORS_ORIGINS the
engine.io WS handshake rejects it and every Socket.IO connection 400s with
"not an accepted origin". Auto-append SERVERKIT_PUBLIC_URL (deduped,
whitespace-stripped) so operators configure the public URL in one place.
Also bumps Flask-SocketIO 5.4.1 -> 5.6.1. Recovered from a local stash.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 19:44:47 -04:00
Juan DenisandClaude Opus 4.8 c4ddc6096d fix(db): migration 008 — fix server_metrics.id type for SQLite autoincrement
The id column was declared BigInteger, which on SQLite is NOT a rowid alias
so it never autoincrements — every metrics insert failed with "NOT NULL
constraint failed: server_metrics.id" and the dashboard silently sat at zero
once an agent sent heartbeats. The model was already fixed to Integer, but
db.create_all() doesn't ALTER existing tables, so deployed SQLite installs
stayed broken. This migration detects the wrong column type on SQLite and
rebuilds the table (historical samples are disposable); Postgres/MySQL are
left untouched where BIGINT autoincrement works. Chains off 007. Recovered
from a local stash.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 19:44:45 -04:00
Juan DenisandClaude Opus 4.8 8aa760bf02 chore: normalize line endings to LF per .gitattributes
Four files were stored with CRLF in git while .gitattributes mandates
`* text=auto eol=lf`, so git reported them as perpetually "modified":
  - ROADMAP.md
  - backend/app/__init__.py
  - backend/app/models/__init__.py
  - frontend/src/App.jsx

One-time `git add --renormalize` rewriting the stored blobs to LF. Pure
line-ending change — `git diff --ignore-cr-at-eol` is empty, i.e. zero
content was altered. Windows scripts (*.ps1/*.bat/*.cmd) are untouched;
.gitattributes pins them to CRLF and they were correctly skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 16:29:05 -04:00
Juan DenisandClaude Opus 4.8 0f9014e189 chore: gitignore scratch .png/.jpeg/.jpg images at repo root
*.png was already ignored but *.jpeg/*.jpg were not, so a stray root
screenshot (image9.jpeg) showed up as untracked while its .png siblings were
correctly hidden. Add the jpeg/jpg rules alongside the existing *.png with a
short comment. The force-included brand assets
(agent/internal/setupui/serverkit.ico + serverkit_header.png) are unaffected:
the existing "!" negations below still keep them tracked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 16:17:52 -04:00
Juan DenisandClaude Opus 4.8 f295103f29 fix(pairing): verify Ed25519 proof-of-possession on pairing poll
During short-code pairing the agent submitted its Ed25519 public key at
enroll time, but the panel never checked that the poller actually held the
matching private key -- identity rested entirely on the bearer
enrollment_secret. Anyone who captured that secret (e.g. from a log or proxy)
could claim the device's credentials.

The agent now signs the enrollment_id with its pairing keypair and sends the
base64 signature as X-Enrollment-Signature on each poll. pairing_service.poll
verifies it against the enrolled hex pubkey (cryptography Ed25519) and rejects
a present-but-invalid or wrong-key signature with InvalidEnrollmentError,
before touching any state. So a stolen enrollment_secret alone no longer
suffices -- the attacker also needs the private key.

Verification is best-effort for backward compatibility: a poll with no
signature still works (older agents), and becomes mandatory once signing
agents have rolled out (tracked in SECURITY.md).

Agent side: KeyPair.Sign(); pairing.Client gains an optional keypair via
SetKeyPair(), wired in at both call sites (cmd/agent/pair.go and
internal/pairdriver/driver.go); Poll attaches the signature header.

Verified: backend 89 passed / 12 skipped (4 new PoP tests: valid sig accepted,
invalid rejected, wrong-key rejected, no-sig backward-compatible); go build
./... + go vet + go test ./... all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 16:08:32 -04:00
Juan DenisandClaude Opus 4.8 a01ccc4aee fix(agent): fail-closed agent registration + surface stuck-command errors
Two correctness defects from the deep-dive review.

B. register_agent left a stale in-memory "online" entry when the DB write
   failed. The except block rolled back the session but kept the
   ConnectedAgent in _agents, so the gateway still emitted auth_ok and
   command routing targeted an agent with no backing AgentSession row
   (online/offline desync). register_agent now returns Optional[str]: on DB
   failure it removes its own in-memory entry (guarded on identity so a
   concurrent reconnect isn't clobbered) and returns None. Both callers —
   the WS gateway (on_auth) and the poll /connect endpoint — now emit
   auth_fail / HTTP 500 instead of reporting a connected agent. A stale
   'online' server row with no in-memory agent is self-corrected by the
   heartbeat reaper.

C. send_command swallowed the final command-record DB update on the
   timeout/result path: a failed commit left the row stuck in 'running'
   with no log line, making it invisible. Now logs the exception (the row
   is still left for a future sweep, but it's now diagnosable).

Adds a regression test that register_agent returns None and rolls back the
in-memory registration when the session write fails.

Full backend suite: 89 passed, 12 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 16:08:13 -04:00
Juan DenisandClaude Opus 4.8 9867380737 fix(agent): harden gateway — locked rate limiter, safe stream/ack, nonce ordering
Cluster of lower-severity agent-gateway defects from the deep-dive review.

agent_gateway.py:
- _check_auth_rate_limit was an unlocked read-modify-write of a module dict
  under async_mode='threading' (on_auth + poll /connect race it), and the
  dict grew without bound from one-off IPs. Now guarded by a threading.Lock
  with a periodic sweep that drops aged-out buckets.
- on_stream crashed with AttributeError on a malformed/partial frame with a
  null/non-string channel (precisely what the poll fallback exists to
  tolerate). Guard the channel type and the container_id split; drop the
  bad frame instead of killing the handler.
- on_credential_update_ack swallowed exceptions without db.session.rollback(),
  leaving a poisoned SQLAlchemy session for the next user on the worker.
- Replaced all print() with module logging (info/warning/exception), so
  auth/IP-block/rotation events are level-controllable and structured.

agent_registry.verify_agent_auth:
- Consume the replay nonce ONLY after the HMAC signature is verified.
  Previously the nonce was recorded first, so a forged request (valid
  agent_id+prefix+nonce, bogus signature) burned the nonce and locked the
  real agent out when it next presented it. Reordered; added a regression
  test (test_failed_signature_does_not_consume_nonce).
- Fixed the misleading "5 minute window" comment (the check is 60s) and
  documented that the 60s window is the only replay guard for the nonce-less
  HTTP-poll transport.

NOT changed (deliberately, to avoid breaking agents):
- Nonce is left optional. The HTTP-poll transport signs agent_id:timestamp
  with no nonce (auth.Sign, "nonce":""); requiring it would lock out every
  poll-fallback agent. Making it mandatory needs a coordinated Go-agent
  change and is tracked in SECURITY.md.
- Per-message session-token validation remains a documented TODO; it
  requires a protocol change on both the Go agent and backend.

Full backend suite: 84 passed, 12 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 15:34:50 -04:00
Juan DenisandClaude Opus 4.8 a2da251bb8 chore(agent): stop tracking committed MSI build artifacts
serverkit-agent-1.4.17-x64.msi (~26 MB) and its .wixpdb were committed in
4db9300, before the ignore rules for build output existed — tracked files
override .gitignore, so they lingered in the repo. The rest of the agent
artifacts (agent/dist/*.exe ~233 MB, the ~90 other files in
packaging/msi/output/, root dist/) were already untracked/ignored.

Untracks the two stale artifacts (kept on disk) and hardens .gitignore:
add /packages/ (the agent-release.yml output dir) plus global *.msi /
*.wixpdb so stray build output can't be committed again. Release artifacts
belong in GitHub Releases via .github/workflows/agent-release.yml.

The *.syso Windows resource objects under agent/cmd/agent/ are deliberately
left tracked: they're build INPUTS embedded by the Go toolchain at build
time (like the force-included .ico/.png brand assets), not distributables.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 15:14:02 -04:00
Juan DenisandClaude Opus 4.8 9de7ecf7c1 fix(agent): Windows file browser — per-entry paths + drive enumeration
The remote file browser was broken because handleFileList returned entries
with only name/is_dir/size/modified and NO `path`. The frontend keys,
selects, and navigates on entry.path, so remote browse was broken on every
OS (entry.path was undefined), and Windows additionally had no way to reach
drives other than the agent process's working drive.

Agent (file_handlers.go + build-tagged helpers):
- Emit an absolute, forward-slash `path` for every entry and for the
  directory itself (filepath.ToSlash), giving the panel one canonical
  separator regardless of the agent OS.
- Normalize the inbound path with filepath.FromSlash before any os call so
  os.ReadDir and the allowlist validation work with Windows separators.
- On Windows, an empty/"/" browse request enumerates logical drives
  (GetLogicalDrives) as virtual directory entries instead of silently
  listing one drive. enumerateDriveRoots() is build-tagged: real on
  windows, nil elsewhere (falls through to "/"). The allowlist still gates
  whether the panel can enter each drive.

Frontend (FileManager.jsx):
- deriveParent and breadcrumbs are drive-aware: "C:/Users" -> "C:/" (not
  "C:", which is Windows' "current dir on C:"), and a drive-rooted path
  builds crumbs from the drive list instead of bogus "/C:" paths. POSIX
  paths are untouched (changes are guarded by drive-letter regexes).

Verified: go vet + go test ./... (host) green incl. new
TestHandleFileListEmitsForwardSlashPaths; cross-builds linux/darwin/
windows-arm64; eslint + vite production build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 15:04:45 -04:00
Juan DenisandClaude Opus 4.8 e9172f7af6 fix(agent): drop stale socket and fail in-flight commands on reconnect
When an agent reconnected, register_agent overwrote the in-memory
ConnectedAgent for the server but never disconnected the old socket or
drained its pending commands. Callers blocked in send_command() on the old
agent's result_queue were never signalled — they waited out the full
per-command timeout and returned a misleading "Command timeout" even though
the agent was back online (the late result would resolve against the new
agent and be unreachable). The agent was also briefly left holding two live
sockets.

On replacement, register_agent now (after releasing the lock to avoid
deadlocking with the disconnect handler):
- puts an AGENT_RECONNECTED error on each orphaned pending command's
  result_queue so blocked callers return immediately, and
- disconnects the old WS socket (guarded: only for ws transport, only when
  the socket id actually changed, only when socketio is initialized).

The old socket id is already removed from _socket_to_server before the
disconnect, so the resulting disconnect handler is a no-op for the freshly
registered agent. Adds two regression tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 14:46:56 -04:00
Juan DenisandClaude Opus 4.8 5598a1c6e2 fix(agent): rate-limit the poll /connect auth path
The WebSocket auth path (agent_gateway.on_auth) throttles auth attempts
per-IP via _check_auth_rate_limit, but the REST long-poll equivalent,
POST /api/v1/agent/connect, did full HMAC auth with no throttle. That left an
unthrottled credential-stuffing surface against agent auth while the WS path
stayed protected.

connect() now calls the same shared limiter before verify_agent_auth and
returns 429 when the per-IP window is exhausted. Adds a regression test that
drives the real endpoint through the limit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 14:46:47 -04:00
Juan DenisandClaude Opus 4.8 749152c09e fix(agent): fail-closed auth + heartbeat reaper reconnect race
Two defects in the agent control plane found during the deep-dive review.

1. verify_agent_auth() failed OPEN on a missing secret (critical). The HMAC
   check was wrapped in `if api_secret:`, so when get_api_secret() returned
   None (NULL ciphertext, or a decryption error it swallows) the signature
   block was skipped and execution fell through to "authenticated" — reducing
   agent auth to agent_id + api_key_prefix, both non-secret observable values.
   Now mandatory: no decryptable secret => log + track_auth_attempt(False) =>
   return None. Also added Server.get_pending_api_secret() and select the
   secret matching whichever prefix matched, so signatures actually verify
   during a key-rotation window instead of hitting the old bypass.

2. Heartbeat reaper evicted freshly-reconnected agents (TOCTOU race).
   _check_heartbeats snapshotted dead server_ids under the lock then released
   it; _handle_agent_timeout popped unconditionally. An agent that reconnected
   in that window (new ConnectedAgent, new socket_id) got clobbered and flipped
   offline right after reconnecting. The snapshot now carries socket_id, and
   _handle_agent_timeout re-validates under the lock (same socket AND still
   stale) before evicting. Extracted HEARTBEAT_TIMEOUT so the scan and the
   re-check can't drift; added the missing rollback on error.

Adds tests/test_agent_registry_security.py (5 tests). Full suite: 80 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 14:38:30 -04:00
Juan DenisandClaude Opus 4.8 eab1a35794 docs: sync docs with dev, add CHANGELOG/SECURITY, document Windows agent
dev was ~149 commits ahead of the last release with no changelog and several
stale docs. This brings the user-facing docs in line with what actually ships.

New:
- CHANGELOG.md — curates the unreleased panel work (agent fleet, plugins,
  status pages, cloud provisioning, RHEL support, installer hardening).
- agent/CHANGELOG.md — the agent release workflow links to this on every
  published release; it was a broken link. Now documents the agent's
  independent release line.
- SECURITY.md — vulnerability reporting policy + agent trust model
  (agent.key is host-equivalent; Features.Exec off by default; single-worker).

Fixes:
- Stale version stamps: docs/README.md and docs/API.md said 0.9.0 -> 1.6.7.
- Supported OS: README/INSTALLATION said Ubuntu/Debian only; installer also
  covers Fedora and RHEL/Rocky/AlmaLinux.
- Dead links/placeholders: API.md wiki link, INSTALLATION.md "yourusername".
- Windows agent + short-code pairing were undocumented; added to README,
  agent/README.md, and docs/pairing.md (incl. corrected host-stable
  credential-key derivation — USERNAME was dropped in agent 1.6.14).
- Documented the single gevent-websocket-worker deployment constraint in
  CLAUDE.md and docs/ARCHITECTURE.md.
- CONTRIBUTING.md: removed reference to a non-existent frontend test suite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 14:38:15 -04:00
Juan DenisandClaude Opus 4.7 ed68cdccb5 Support Rocky/RHEL + fix Docker install on Fedora
install.sh:
- Detect rocky/almalinux/rhel/centos as OS_FAMILY=rhel.
- The Fedora system-deps block now also runs for rhel; auto-install
  epel-release on RHEL-family.
- Docker install on Fedora/RHEL no longer pipes get.docker.com. That
  script recently added docker-model-plugin to its package list, which
  doesn't exist in docker-ce repos and aborts the install on Fedora 39.
  Replace it with explicit `dnf config-manager --add-repo` +
  `dnf install docker-ce docker-ce-cli containerd.io
  docker-compose-plugin docker-buildx-plugin`.
- Node.js and nginx installs route through dnf for rhel too.
- SELinux setsebool runs for rhel too.

scripts/test/full-stack-test.ps1:
- fedora image bumped from fedora39 (EOL Nov 2024) to fedora40 (current).
- Test-VagrantInstall + Test-VmInstall: defensive read of status file.
  Previously `(Get-Content -Raw).Trim()` crashed with InvokeMethodOnNull
  when the status file was missing/empty (which happens when the VM's
  install script crashed before producing it). Now falls back to 'FAIL'
  cleanly so the run summary still works.

Found by full 5-distro suite: Ubuntu 22/24 + Debian 12 passed; Fedora 39
failed on docker-model-plugin; Rocky 9 failed silently due to apt-vs-dnf
+ status NPE. All three fixed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 16:34:50 -04:00