The hook has zero importers yet was counted in the LEGACY_POLLERS baseline,
holding a phantom slot in the ratchet (a place a new raw setInterval could
hide). Poller debt shrinks 11 sites/10 files -> 10 sites/9 files; the
migration inventory is regenerated to match.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The raw-subprocess census and the thread-ownership guard walked the
filesystem under backend/app, which on a dev box also holds *installed*
extension copies under app/plugins/ that git does not track. The census
therefore measured 28 on dev machines but 24 on CI's clean checkout — four
sites of invisible ceiling slack, a stale thread classification for the
locally-installed analytics extension, and a red migration-inventory
freshness test on every CI run since the range landed.
Both instruments now intersect their walk with git ls-files (falling back
to walking everything when git cannot answer), the analytics thread entry
is removed (extension repos own their own threads), the ceiling ratchets
down 28 -> 24, and the inventory is regenerated. Proving test:
test_untracked_files_are_not_counted plants an untracked file with a raw
call inside app/ and asserts the census does not see it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The plan-76 completion record cited a75f435f (clipboard) and 3ac566cb (first
poller wave) — pre-rebase hashes unreachable from HEAD; the landed commits
are e1a5a5e1 and 1503c493. The plan-77 record's '98 query sites' for the
Server SoftDelete tail measures 99 today, so it is recorded as approximate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four passages still described the pre-convergence world: bare @jwt_required()
as the route guard (now the frozen legacy population behind auth_required()
and the rbac identity door), plain JSON errors (now typed envelopes with
request ids), sockets.py as a pile of handlers (now the channel registry,
room grammar, and run envelope), and deploy_log/deploy_status as the deploy
channel (now a compatibility dual-emit under run_log/run_status). Also names
the model mixin suite and canonical status vocabulary in the Models row.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
All workstreams A-G executed 2026-08-19; open questions resolved
(D1 read-edge normalize, C2 permanent dual-read on the one key, B2
full-dict + _query shaping, E1 dual-emit); deliberate tail named with
owners (Server soft-delete pass, template/security_feed remote-index
non-adopters, the two raw log channels). BASELINE_COUNT 4460 -> 4591.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RunLogStream accepts any RunLike (__run_kind__ + id + to_dict, optional
result/step members): one room grammar run_<kind>_<id>, one batched
persisted run_log/run_status event pair, and a GET
/api/v1/runs/<kind>/<id>/logs?after_id polling twin over the new
run_log_entries table (migration 087). Deploys keep DeploymentJobLog and
dual-emit the legacy deploy_log/deploy_status during the migration
window. The unified Job runtime — which had NO live channel — adopts it
in the consumer (start/succeeded/failed frames; handlers can log via
stream_for(job)). The dead pipeline_* scaffolding was retired in E2.
Gate: emit -> persisted row -> after_id resync contract test; consumer
adoption test. MIGRATION_INVENTORY.md regenerated (census moved).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Names the resolved A and C decisions, the 2026-08-19 commit set, the
intentionally retained semantic duplication with owners and removal
conditions, and the deferred H items. Collection floor 4430 -> 4460.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
docs/MIGRATION_INVENTORY.md is generated from the live censuses and the
frontend boundary checker's new --inventory JSON mode — no hand-typed
numbers, so the plan cannot claim completion while a door is bypassed. A
freshness test fails whenever a ratchet number moves without regenerating
the document, making the regeneration part of every migration commit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The recorded 56 was a measurement artefact — the probe counted any return it
could not resolve to a status, including the unexpected_response() calls the
migration had just added. The real count is 1. The remeasurement instead found
a hole in milestone B's ratchet, now closed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Also records why the remaining milestone A work (605 @jwt_required routes) is
not a mechanical swap: auth_required() accepts API keys where @jwt_required()
does not, so converting a route expands the authenticated surface and needs a
decision about which routes should be API-key reachable — not just an edit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Names why the sniffing row is ratcheted rather than migrated: file_service owns
12 of the 23 sites and has eight callers, so converting it to raise ahead of the
envelope decision would leave two contracts on one service — the failure mode
the plan exists to remove.
Adds a status table for the five drift/bug rows and the five ratchets now
holding them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Corrects the audit's framing of the fix: the in-flight guard is not what stops
the schedule stacking — arming the next tick from the previous run's completion
is. Also flags the trap for the remaining 41 migrations: several sites use the
interval effect's dependency array as an out-of-band reload trigger, which
usePolling drops silently.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Keeps the three ways the audit's measurement was off, and names the 56
swallow-and-answer-200 handlers as still open so B cannot read as finished.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Also records the one non-mechanical part of the remaining F3 work: a
.then(onOk, onErr) chain cannot be repointed at copyToClipboard as-is, because
the helper resolves false instead of rejecting and the error callback would
silently stop running.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both drift/bug rows of milestone A are closed. The record keeps the four ways
the audit's own measurements were wrong, since the plan's premise is that these
numbers are the baselines everything else ratchets against:
- the API-key failure is a raised RuntimeError (a 500), not a None
- the inline population has a second, larger two-line shape (87 sites)
- a sixth _require_admin() lives in an extension with no installed live copy
- 12 of 19 private helpers were named get_current_user(), shadowing the door
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds the 2026-08-18 duplication-audit table to plan 76: for each door the
first wave built, how much legacy traffic still walks around it, measured
rather than estimated.
The rows marked drift/bug are the reason this is not filed as tidy-up —
they are behaviour differences shipping today, not repetition. Inline
`User.query.get(get_jwt_identity())` returns None for API-key callers
because only rbac checks g.api_key_user; locally-swallowed exceptions skip
the 500 handler and never reach the error tracker; status codes chosen by
sniffing error strings; pollers with no in-flight guard; raw
navigator.clipboard on HTTP-served panels, where SSL being optional by
policy means those copy buttons simply do not work.
Two rules recorded with it: every row gets a ratchet before its migration
starts (a migration without one regrows), and the drift/bug rows are
scheduled like fixes rather than "migrate when touched".
Scope boundary noted so the three plans do not overlap: the services-layer
subprocess/docker/nginx/SQL-exec doors are plan 75 Round 2 (§G); model
mixins, secret handling, status vocabulary, realtime convergence and
shared infra utilities are plan 77.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
install.sh wrote no TRUST_PROXY_HEADERS line at all, so the code default (False)
applied while every host install serves the panel through its own nginx. Per-IP
rate limits, the login brute-force throttle and every audit-log entry therefore
keyed on 127.0.0.1, which quietly weakened all three -- one abusive client shared
a bucket with everyone else. .env.example claimed true, so the two disagreed and
it looked configured.
Trusting X-Forwarded-For is only safe when nothing can reach gunicorn without
traversing a proxy we control, so the switch follows that condition rather than
assuming it. On a host install it holds: render_service_unit binds gunicorn to
127.0.0.1 and bootstrap_firewall opens SSH + 80/443 + PANEL_PORT and never the
backend port. backend_bind_host() is now the single source of truth for both the
systemd unit and the trust decision so the two cannot drift, and
backend_is_loopback_only() gates the setting.
- stock install: TRUST_PROXY_HEADERS=true, TRUSTED_PROXY_HOPS=1
- SERVERKIT_EXTERNAL_PROXY=1: true with 2 hops, unchanged
- SERVERKIT_BIND_HOST not loopback: false plus a warning. The port is directly
reachable there, so the header is attacker-chosen, and a forged IP is worse
than a wrong one because it defeats the throttles that consume it.
Re-runs backfill it into installs that predate this, via _env_default_key, which
fills a gap but never overrides an uncommented value -- flipping someone's
deliberate false back on would be the worst kind of surprise.
.env.example moves to false on purpose: that file is the Docker/manual path and
docker-compose.yml publishes 5000 on all interfaces, so true was the unsafe
default there. Host installs never read it. SECURITY.md claimed "the shipped
nginx deploy sets it", which was untrue until now.
T44 covers all six behaviours, each mutation-checked by line number: disabling
the branch, inverting it to trust an exposed port, and disabling the backfill
each fail exactly one assertion and no others.
Shell suite 121 passed. Backend suite 3542 passed, 48 skipped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Issue #96: an operator fronting the panel with Caddy and a smallstep private CA
hit a redirect loop, and there was no documented path to follow. The
architecture already tolerated this deployment -- the fix was a config file we
already ship -- but nothing said so anywhere.
Adds an INSTALLATION.md section covering both topologies (external proxy ->
ServerKit nginx, full features, HOPS=2; external proxy -> the app directly,
panel-only, HOPS=1), why the loop happens, how to count TRUSTED_PROXY_HOPS,
Caddy (with acme_ca for a private CA), Traefik, Nginx Proxy Manager and plain
nginx snippets, what a private CA does and does not require (the panel never
sees it; a remote agent's host must trust the root), and a symptom-to-cause
table for the loop, stale HSTS, the rejected websocket and wrong client IPs.
Also corrects the Docker documentation, which had drifted badly:
- README (en/es/pt/zh-CN), INSTALLATION and DEPLOYMENT all told people to run
`docker compose up -d` and reach the panel at http://localhost. It starts the
all-in-one container on :5000, and a containerised panel cannot manage its own
host -- now stated up front rather than discovered.
- DEPLOYMENT is a host-install guide that told operators to run
`docker compose build && up` to fix a panel, which would build a second panel
beside theirs, and carried a certbot cron copying certs and restarting a
container that does not exist on a host install.
- CLAUDE.md, AGENTS.md, CONTRIBUTING.md and LOCAL_DEVELOPMENT.md (x3) pointed at
a docker-compose.dev.yml that has never existed; the snippet offering to
create it referenced a frontend/Dockerfile.dev that does not exist either.
Every anchor introduced here was checked to resolve.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 55 Phase 4. EXTENSIONS.md said `permissions` is "**enforced** by the SDK
gate: require_permission(slug, "docker") raises unless docker is declared".
That sentence is true about the function and misleading about the system: the
gate only fires where something calls it, and nothing routes Docker through it.
The SDK exposes no docker, shell, filesystem or network helper at all, and hands
out `db` as raw SQLAlchemy, so an extension that wants any of them imports the
host module directly and no in-process check is involved. Declaring `docker`
does not stop an extension using Docker, and not declaring it does not either.
Both documents now state which capabilities are actually mediated -- today
`agent.command:<action>` and nothing else, because the SDK is the only route to
an agent -- in a per-capability table, and say plainly what declaring does
instead: it tells the operator at install time what the extension claims to
need, and lets them refuse.
SECURITY.md gains an Extension Trust Model section next to the agent one. It
leads with the part that matters most and was never written down: an extension
is in-process code running with the panel's privileges against the panel's
database, nothing sandboxes it, and no permission string changes that. Signing
and sha256 pinning are described for what they are -- integrity of what you
installed, not containment of what it does -- along with the unsigned-prompts,
bad-signature-fails split, and the pip opt-in.
It also explains why the new detail page reports "cannot be observed" rather
than a zero count for unenforceable permissions: absence of evidence is not
evidence of absence, and a "never used" badge there would imply a boundary that
does not exist.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds projects/vaults/shared-variables shots and drops services-cards, whose
list/cards toggle no longer exists.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
feat(ui): one row of chrome per table
A table gets exactly one row of chrome:
[View name v] ....................... [search] [filter] [...]
and nothing else. The row count is gone from it entirely: GridViewPickers
scripts/sign-extension.mjs signs a bundle with an ed25519 author key;
EXTENSIONS.md documents the format, key model, and verdict behaviors.
Generated private keys live in gitignored scripts/keys/.
Adds a new YouTube “Watch the Demo” badge/link near the top badges in the main README and the Spanish, Portuguese, and Simplified Chinese README translations so users can quickly find the product walkthrough.
- install_profile_service: record what install.sh put on the box
(minimal/standard/full), recommended from detected hardware and
overridable; thresholds mirrored by recommend_profile in install.sh
- setup wizard: replace the hard ResourceGate/SetupStepTier with
SetupStepCapacity + SetupStepSecurity
- ResourceAdvisory: inline capacity warning at the point of action
(WordPress/Docker/Services/Deployments), backed by headroom.fits
- RequiresDocker component for docker-dependent surfaces
- install.sh/update.sh: profile detection + tests
(test_install.sh, test_update.sh, test_server_capacity.py)
components/PageSkeleton.jsx already owned `.sk-page` for its loading
archetypes, including `gap: $space-4`. The new shell reused the name, so every
page built on PageLayout inherited a 16px flex gap between the top bar and the
content — invisible on padded pages, but on Terminal/Logs it showed as a band
of page background under the bar, which is exactly the "weird space" reported.
The shell is `.sk-pageshell` now; the skeleton keeps `.sk-page`. The smoke test
gained a vgap assertion (content top must equal the bar's bottom edge), which
is what would have caught this in the first place.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three detail panels rendered their own `.preview-drawer` — fixed positioning,
their own backdrop, their own slide animation, their own close button — so they
had no focus trap, no escape-to-close, and header chrome that only resembled
the shared drawer's. The Terminal process and service panels and Docker's
container logs panel now use ds/Drawer like everything else.
Left alone deliberately: the File Manager preview (it also renders inline as a
column pane, which a Radix sheet cannot do) and Docker's container inspector
(the `dx-*` workspace has its own language by design).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Git hand-rolled the tab-group frame — `page-container--full-bleed sk-tabgroup`
plus its own `__content`/`__inner` wrappers, duplicated across the loading
branch and the main return. It renders through the shared layout now, so the
frame comes from one place and the loading state matches the loaded page.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The console panes were rounded, bordered cards floating inside a `0 16px 16px`
inset — the only page framed that way, which read as stray side padding plus
stray corners against an otherwise flush layout. It now renders through
PageLayout in `fill` mode, like the File Manager: the panes fill the content
region edge-to-edge and keep their internal dividers. Services stays a card
grid, so it re-pads itself instead of running to the edges.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`.sk-drawer__body` is the scroll region and deliberately carries no padding —
some drawers run edge-to-edge — so each body pads its own root. `.cron-form`
did not, leaving every label and input flush against the panel edges. Same
inset as `.cron-drawer` so the two cron drawers line up, plus a note on the DS
Drawer so the next body does not repeat it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Creating a job went through a plain modal with a preset <select> and a bare
cron text field — the schedule was something you had to already know how to
write. It now opens a drawer built on the shared SchedulePicker (presets ·
builder · advanced, with the humanized cadence and the next fire times), plus a
preview of the exact crontab line the form will write.
The drawer owns its own form state, so the page drops the form object, the
reset/open/submit handlers and the /cron/presets fetch that only fed the old
dropdown. The list head gains the section title and the "N of M jobs" count,
with the filter moved to the right where the mock has it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Monitors, a monitor's detail and 90-day uptime strip, Incidents, Events and
Jobs. These double as the render smoke test for the pages this round added.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The dashboard shots were clipped to `.dashboard-page`, which cropped the
sidebar out and made them the only images in the set without the app chrome
around them — next to Services and Docker in the README it read as a bug.
The clip was there for a reason: `.main-content` owns the scroll, so a plain
shot stops at the viewport and `fullPage` does not reach the bottom widgets.
The fix is a per-section viewport instead — a taller frame captures the whole
board WITH the navigation, and `capture.mjs` now honours `section.viewport`
for any page with the same internal-scroll shape.
Also stops the shots claiming work is in flight. The dashboard fixtures
answered the deploy pill with running jobs, so every capture carried a
"2 deploys running" pill over the sidebar. The pill is correct behaviour; it
just has nothing to do with a screenshot of an idle board, so the fixture now
reports settled jobs rather than the CSS hiding a true statement.
31/31 sections still render.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six new sections cover the pages rebuilt this round — the dashboard as it ships,
its edit mode, the widget library, the widget editor, the databases explorer and
the engine catalog drawer. 31/31 sections render.
The capture is a render smoke test, not just a picture, and it earned that here
by surfacing two real bugs that no test could see:
`SearchField` merges its `className` onto its OWN root, so `.skw-lib__search`
IS the `.sk-searchfield` element rather than a wrapper around one. The rule
styling it as a parent matched nothing, leaving the shared `flex: 1 1 240px`
in force — and inside a column-direction filter bar that 240px basis is a
HEIGHT. The widget library's search box was rendering 240px tall.
`GET /auth/me` answers `{user: {...}}`, but Databases read `.role` straight off
the envelope, so `isAdmin` was always false. Every admin got the read-only
console — and with the DDL builder now shipped, would never have seen the button
that runs a CREATE TABLE.
Also carries `postgresql.yaml`, which I left out of the extensions commit.
Without `engine.extensions: true` stock PostgreSQL is not a host for pgvector at
all; the extension tests passed only because the working tree had the change the
commit did not.
Fixture note for whoever edits these next: `/metrics/history` points are NESTED
(`cpu.percent`, `load['1m']`) and carry no network data, `/servers/available` is
a bare array, and `DeployPill` queries `/deployment-jobs` twice and sums — a
single unfiltered payload makes it claim eight running deploys.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- List all installer-supported distros (Ubuntu 22.04+, Debian 12+,
Fedora, RHEL/Rocky/AlmaLinux 9+) instead of Ubuntu only
- Add Arch row (x86_64, ARM64) matching installer platform detection
- Clarify Docker is optional for the panel itself
- Note the panel's ~180 MB RAM / ~500 MB disk footprint and that it
runs on 1 GB VPSs, spare laptops, and Raspberry Pi homelabs
`contributions.widgets` mounts a component at a host-chosen slot. That is the
wrong shape for the new dashboard, where the user decides what goes on a board
and where. So this adds `dashboard_widgets` alongside it: a registry of
placeable widget TYPES that surface in the widget library, each instance
positioned, sized and configured by the person looking at it.
The two are easy to confuse, so the docstring, the manifest spec and
EXTENSIONS.md all now say plainly which is which, and a test asserts a
dashboard widget never leaks into the fixed-slot list.
SDK_VERSION 1.0.0 -> 1.1.0 (added surface, older extensions unaffected),
bumped in lock-step across the JS export and the backend mirror.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fleet list carried a rail of its own — a health dial, a status nav and a
groups nav — plus a workbar, a command bar, bulk-select and a per-row action
menu. Every number those showed is a column or a filter here, so the page is
one filter row and one table: status segments with counts, a group select,
"4 of 4 servers · 2 online", then Server / Host / Agent / Status / CPU /
Memory / Disk / Last seen. Row actions are gone because ping and delete
already live on the server's own page, which is where rebooting or deleting a
machine belongs.
Offline and pending rows show a dash rather than empty gauges: a 0% bar is a
claim, and those hosts are not reporting.
Add Server was written as a right-side drawer, then pointed at the centred
Dialog primitive while keeping its own `position: fixed; right: 0` geometry.
The primitive's `left: 50%` and -50% transform still applied on top, which is
what rendered the panel half off-screen and clipped. It runs on the shared
Drawer now, so the slide-in, focus trap and overlay come from one place.
`.servers-page { padding: 0 }` was a leftover from the old full-bleed
workspace and was overriding the tab group's padding, running the table under
the right edge of the viewport. Removed, with its layout hook.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>