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>
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>
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>
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>
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>
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>
/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>
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>
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>
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>
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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
#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>
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.
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>
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>
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>
- 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>
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>
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>
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>
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>
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>
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>
*.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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>