* feat: add compose discovery for setup preflight and sidebar empty state
Expose read-only compose discovery via GET /api/stacks/discovery and setup
diagnostics. Replace the blank sidebar with path-aware discovery and move
adopt into a dedicated dialog with a three-tab Create Stack flow.
* test: assert post-setup handoff via sessionStorage read-back
The Setup preflight test spied on Storage.prototype.setItem to check the
post-setup adopt handoff. When the jsdom storage probe fails and the test
harness swaps in its in-memory storage stub (which does not extend Storage),
that stub's setItem never touches Storage.prototype, so the spy records zero
calls and the assertion fails even though the component wrote the value.
Read the value back with sessionStorage.getItem instead, matching how every
other storage test in the suite asserts. This is robust to both the native
jsdom storage and the in-memory fallback.
* fix(setup): surface compose discovery as a preflight check row
Drop the Setup discovery banner and non-working Review button. Show
counts as a pass row in EnvironmentChecks (Setup only) and keep
Enter Sencho as the handoff that opens adopt when candidates exist.
* test(setup): cover zero-count discovery row omission
* fix(stacks): widen adopt scan to any yaml and rename into place
Homelab layouts often use nginx.yml or plex.yml. Surface those for
adopt (except overrides), rename to compose.yaml on move so stacks
register, and reset the confirm UI when a move fails.
* feat: add confirmed Take down stack action with optional volume removal
Expose Take down in the stack header and sidebar with a confirmation dialog
that runs compose down while keeping the stack definition on disk. Optional
volume removal is gated by node capability and stack:deploy permission, with
remote gateway preflight before proxying removeVolumes requests.
Closes#1582
* fix: reset take-down volume checkbox when dialog closes
* test: align getStackMenuVisibility assertions with showTakeDown key
getStackMenuVisibility now returns a fifth lifecycle flag, showTakeDown,
but three exhaustive toEqual assertions still listed only the prior four
keys and failed. Add the expected showTakeDown value to each: true for
the partial and exited running-stack cases, false for the self stack.
* test: cover Take down visibility for running non-self stacks
The getStackMenuVisibility assertions exercised the partial and exited
branches and the self-stack guard, but not the raw === 'running' literal
that drives showTakeDown for a normal running stack. Add a case so a
regression dropping 'running' from that check is caught.
* fix: drop Take down from header overflow and wire activity shortcut
Remove duplicate Take down from More actions.
Keep inline button when running, sidebar menu, and Cmd+ArrowDown.
Record stack_taken_down in activity on successful POST /down.
Compare runtimeResourceName against the project-prefixed default instead
of the compose key so networks like tailscale: { name: tailscale } are
not mis-resolved as network_tailscale in Drift, Fleet summary, and preflight.
Fixes#1581
* fix: repin semver compose tags during fleet self-update
Fleet updates failed when docker-compose.yml pinned a semver tag because recreate reused the on-disk pin. Pull the target image first, rewrite semver pins via the update helper, and block digest or unresolved pins with fast 409s.
* fix: update OFFLINE_META shape in capability and node-registry meta tests
* fix: increase masthead rail shimmer visibility
Bumped shimmer highlight opacity from 25% to 50% and reduced
animation duration from 11s to 5s, matching the login page
AuthCanvas shimmer precedent (via-white/60 at 4.5s).
* fix: reduce masthead rail opacity to match login page pattern
Changed all MastheadRail background classes from 100% to 70% opacity
(bg-brand → bg-brand/70, etc.) so the shimmer highlight reads as a surface
reflection rather than a detached floating bar. Matches the AuthCanvas
login card precedent.
* fix: improve masthead rail glow visibility
Replaced the invisible inset box-shadow glow (10px blur inside a 3px
element) with a white overlay that pulses opacity 0.15-0.55 over 4s.
Sped up glow animation from 5.5s to 4s for a more noticeable breath.
* fix: replace cursor-follow tooltip with standard Radix tooltip in sidebar rows
Replaced the Cursor/CursorFollow animate-ui primitives in StackRow
with the standard Radix Tooltip/TooltipTrigger/TooltipContent already
used throughout the app. The custom cursor dot that followed the mouse
is gone; tooltips now appear as static popovers on hover/touch.
* fix: wrap tooltip icon triggers in span for Radix compatibility
TooltipTrigger asChild requires a native element to forward props.
Lucide icon components did not reliably receive data-state and event
handlers, so tooltips on the check-failed and git-pending indicators
did not open. Wrapped each icon in a span.
* fix: rename Notification Routing to Routing in settings sidebar
* fix: replace Timeline/All tasks buttons with SegmentedControl
The two loose Button elements had indistinguishable selected/unselected
states (secondary vs ghost). Replaced with the SegmentedControl component
already used elsewhere on the same page, which has a clear active
highlight indicator.
* fix: standardize Blueprint empty state headings to font-heading
Replaced manual font-serif [font-style:var(--heading-style)] with the
font-heading class on the main heading and three step titles so the
Deployments empty state follows the same heading convention as the
rest of the app and respects the Calm/Signature theme toggle.
* fix: remove custom tooltip styling, use standard TooltipContent defaults
The RowTooltip wrapped its label in a font-mono text-stat-value span
instead of using the TooltipContent base styling (text-xs
text-popover-foreground) like every other tooltip in the app.
* Revert "fix: remove custom tooltip styling, use standard TooltipContent defaults"
This reverts commit 209fbd2742.
* fix: standardize all tooltips to match sidebar RowTooltip design
Wrapped every TooltipContent child in a font-mono text-xs
tabular-nums text-stat-value span so all tooltips across the app
share a consistent look. Previously some used plain text, others
used <p> elements with different fonts and sizes.
* Revert "fix: standardize all tooltips to match sidebar RowTooltip design"
This reverts commit 9e1d97d39e.
* fix: bake standard tooltip styling into TooltipContent component
Added font-mono tabular-nums text-stat-value directly to the
TooltipContent base styles so every tooltip in the app inherits
the same look without per-instance wrappers. Removed the now-
redundant span from StackRow's RowTooltip.
* fix: replace custom tooltip with shadcn/ui Radix tooltip
Replaced the glass/blur-styled tooltip with the standard shadcn/ui
pattern: bg-foreground/text-background, arrow pointer, proper
slide/fade animations, and w-fit max-w-xs sizing. This gives all
tooltips a consistent dark-solid look across the entire app.
* fix: apply glass/blur design to shadcn tooltip component
Replaced the solid bg-foreground/text-background style with the app's
glass design: bg-popover, border-glass-border, backdrop-blur, and
text-popover-foreground. Kept the shadcn structure (Portal, Arrow,
slide/fade animations).
* fix: replace native title tooltips with Radix tooltips on Fleet toolbar
Converted Refresh, Export Dossier, Search, Sort direction, Node Update,
and Manage Nodes buttons from native HTML title attributes to the shared
Radix Tooltip component so they match the app-wide glass tooltip design.
* fix: replace native title tooltip on Fleet Snapshots upload button
* fix: replace native title tooltips with Radix tooltips on Resources page
Converted all actionable button tooltips (Open stack, Inspect image,
Scan, Browse volume, and delete buttons) plus the SenchoBadge
informational tooltip. Wrapped disabled protected-resource delete
buttons in span triggers so tooltips fire on disabled elements.
* fix: replace native title tooltips with Radix tooltips on Security page
Converted scanner info icon, exploitability n/a badge, and scan
image button from native HTML title attributes to the shared Radix
Tooltip component.
* fix: replace native title tooltips with Radix tooltips on Schedules and Settings pages
Converted Run now, Execution history, Edit, Delete buttons on Schedules;
Download/Delete on Cloud Backup; Send test/Edit/Delete on Notification
Routing; Reset 2FA on Users; and Edit/Remove suppression on
Suppressions panel. Skipped SettingsSection/Modal title props which
are React component props, not native HTML attributes.
* feat: add copy button to execution history details column
Replaced the plain truncated Details cell with a flex layout containing
a copy button. Users can now copy the full error/output text instead
of relying on the native title tooltip to read long messages.
* fix: change tab highlight from neutral to brand color
Replaced bg-glass-highlight with bg-brand/20 on all TabsHighlight
instances and updated TabsTrigger active state to text-brand,
matching the SegmentedControl selected style. Works on both
dark and light themes.
* fix: restructure Resources page tabs to match Fleet/Security pattern
Moved tab band out of the outer card into a standalone full-width
element. Replaced custom FilterToggle with Fleet-style Button
toggles. Added search inputs per tab. Wrapped each resource table
in a card container matching the Security Images table design.
* fix: match Resources tab font size to Fleet and Security
Removed explicit text-xs from Resources TabsTrigger elements so they
use the default text-sm, matching Fleet and Security tab typography.
* fix: restore missing closing bracket on TabsTrigger tag
* fix: add aria-labels to schedule action buttons, update test selectors
Replaced native title attributes with aria-labels on Run now,
Execution history, Edit, and Delete buttons so the Radix tooltip
conversion does not break test selectors that relied on title.
Updated tests from findByTitle to findByRole.
* fix: add fingerprint-based dismiss to post-deploy scan banner
Created useScanBannerDismiss hook following the usePreflightDismiss
pattern. The banner now persists dismissal in localStorage keyed to a
fingerprint of scan status + attemptedAt. Dismissal survives page
reloads for the same scan outcome, and the banner automatically
reappears when a new scan runs or the status changes.
* fix: update Configuration Status card labels and add new rows
Notifications section: renamed Notification agents to Channels,
Notification routing to Routing, added Mute Rules row linking to
notification-suppression settings.
Security section: added Trivy installed Yes/No row, renamed
Vulnerability scanning to Scan policies.
Backend: added trivyInstalled and suppressionRules fields to
the /dashboard/configuration payload.
* fix: style Trivy row as badge and link to Security scanner setup
* fix: remove Mute button from stack anatomy panel header
* Revert "fix: remove Mute button from stack anatomy panel header"
This reverts commit 16843a81b5.
* fix: remove Mute button from stack anatomy panel header
* fix: remove unused stackMuteActions imports and props from StackAnatomyPanel
The removal of the ActivityMuteKebab rendering left orphaned imports
and props that cause TS6133 errors under strict mode. Clean up the
imports, prop type, destructuring, and caller prop passes.
* fix: refine anatomy tab sizing and add pulsing scroll chevron
Changed anatomy tabs from 12px fixed height to 11px with py-1
padding, matching the Fleet tab approach. Bumped Files/Edit buttons
from 10px to xs for better visibility. Added subtle animate-pulse
to the right scroll chevron to hint at overflow tabs, gated behind
the Reduced effects appearance setting.
* fix: link reclaim hero number to heading style
* fix: replace native title tooltips with Radix tooltips on stack detail page
Converted Expand/Collapse logs, Stats unavailable, Copy service URL,
Dismiss findings, Likely secret badge, Move up/down, and Remove
buttons from native HTML title attributes to the shared Radix
glass tooltip component.
* fix: replace native title tooltip on image source link button
* fix: add tooltips to container logs and bash session buttons
* fix: add tooltip to download logs button
* fix: add tooltip to copy digest button
* fix: replace cursor-follow tooltip with Radix tooltip on severity badge
* fix: add tooltips to density toggle buttons
* feat: add expand button to multi-container health panel
Added an expand/collapse toggle next to the density controls that
expands the containers panel to fill the column and hides the logs
section. The expand and logs-expand are mutually exclusive. Only
appears for stacks with more than one container.
* fix: reduce Structured/Raw terminal toggle font to 10px
* fix: set compact density as default for multi-container stacks
* fix: change Feedback to Open New Issue with GitHub issues link
* fix: replace ExternalLink with GitHub icon for Open New Issue menu item
* fix: restore ExternalLink import needed by other menu items
* fix: default host threshold alerts to off for new installations
* fix: resolve CI typecheck and test failures
Broaden MenuRowProps.icon type to accept custom SVG components alongside
lucide icons, fixing TS2741 on GitHubIcon import.
Update host_alerts_enabled seed test assertion to match the new opt-in
default of off (0).
* fix: update ContainersHealth tests for compact default density
Adjust three tests to reflect the new default of compact mode:
- Rename 'detailed mode is the default' to 'compact mode is the default'
- Update sparkline visibility checks to switch to detailed first
- Rename remount test and verify reset to compact (new default)
* feat: block self-stack lifecycle ops with UI and preflight guardrails
Refuse update, deploy, down, stop, and delete when the stack matches Sencho's compose project.
Return 409 self_stack_protected. Expose isSelf on /statuses and disable guarded UI actions.
Add SelfStackProtectedDialog and self-managed-stack preflight warning.
Closes#1564
* fix: add missing stackSelfFlags mock to useSidebarContextMenu test
The production hook now reads stackListState.stackSelfFlags[file], but the
test mock did not include it, causing 6 tests to fail with TypeError:
Cannot read properties of undefined (reading 'web.yml').
* fix: harden self-stack protection during startup
Add a global environment preflight warning when Sencho is managed inside COMPOSE_DIR.
Align status decoration and route guards on Docker label fallback detection.
Block rollback and service-level stop on the protected self stack.
* fix: add self_stack_location to diagnostics-route expected check IDs
Drift compared raw compose YAML to runtime, so ${VAR:-default} image
expressions false-positive as image-mismatch. Source the declared side
from docker compose config instead, matching deploy-time resolution.
Fixes#1572
* feat: move core Blueprint orchestration to Community tier
Blueprints CRUD, reconciliation, and drift modes are now available on
Community. Pin remains Admiral-only via Federation placement controls.
* test: update NodeCard cordon tests for Admiral-only tier gate
Cordon now requires both isPaid and node:manage permission, matching the
backend requirePaid + requirePermission guard. Three tests still used
isPaid:false but expected the menu to be visible.
Only one local node is allowed. Creating a second local returns 409,
and the last local node cannot be deleted or converted to a remote type.
Existing duplicate local nodes from older versions are preserved and can
be cleaned up individually. Zero-local recovery auto-assigns the default
flag. Frontend delete surfaces and the Add Node form respect the new
invariant.
Enforced in DatabaseService (addNode/updateNode/deleteNode guards) and
routes (error translations). Legacy test fixtures use raw SQL helpers.
Detect services with build: in the update preview and run compose build --pull
plus pull --ignore-buildable when Update is triggered on those stacks, while
keeping the existing pull-only path for image-only stacks.
* feat: acknowledge Compose Doctor preflight findings per stack
Add node-scoped preflight acknowledgements with read-time filtering.
Supports four expiry modes and activeStatus for banner, tab dot, and readiness.
* fix: align preflight acknowledge UI with design system
Use Combobox, modal chrome, mono fields, and non-destructive clear confirm.
* fix: update test mocks to match new preflight field names
The preflight-acknowledgements feature renamed status-\>activeStatus and
highestSeverity-\>activeHighestSeverity in the preflight report shape. The
corresponding test mocks in three files still used the old field names,
causing 6 test failures across backend and frontend.
- backend: update-guard-service mock now passes activeStatus
- frontend PreflightPanel: Report interface and report() helper now include
activeStatus, activeHighestSeverity, activeCount, acknowledgedCount
- frontend StackAnatomyPanel doctor: mock API response now includes
activeHighestSeverity and activeStatus
LSIO encodes read-only mounts in volume path (e.g. /var/log:ro). Map
host_path and optional correctly so fail2ban and similar templates
generate valid compose specs.
Fixes#1554
* feat: add sidebar update indicator toggle and Stack Health badge
- Add image_update_sidebar_indicators setting (default off, node-scoped)
- Gate the Updates filter chip and sidebar status indicators on the setting
- Add "Update available" badge to Stack Health table (always visible)
- Extend ImageUpdateStatus with sidebarIndicators boolean
- Poll /api/image-updates/status alongside /detail in useImageUpdates
- React to SENCHO_SETTINGS_CHANGED for instant toggle propagation
- Reset sidebar state on node switch; generation-guard stale responses
- Disable toggle when status is null (loading) or field is absent (old node)
- Wire stackUpdates through ViewRouter → HomeDashboard → StackHealthTable
- Update settings registry, operator docs, and sidebar/dashboard docs
* fix: guard against stale node renders, memo drift, and cross-node error toasts
- Track owning node ID in useImageUpdates state so React never renders
node B with node A's data before the passive effect resets (P2)
- Replace incorrect stackUpdates dependency with sidebarStackUpdates in
chipFilteredFiles useMemo (P3)
- Guard the error toast in handleSidebarIndicatorsChange so a stale PATCH
failure from node A does not surface while viewing node B (P3)
* fix: default sidebar update indicators to on (opt-out)
The sidebar indicators are a safe convenience that most users want.
Switching the default from off to on matches the opt-out convention
used by prune_on_update, reclaim_hero, and health_gate_enabled.
* fix: fall back to enroll token when pilot tunnel JWT is rejected
On HTTP 401/404 upgrade rejection, delete stale pilot.jwt and retry with SENCHO_ENROLL_TOKEN.
* test: import pilot agent module after DATA_DIR is set in fallback test
The auth-fallback test statically imported pilot/agent, which freezes its
pilot.jwt path from DATA_DIR at module load, before setupTestDb redirects
DATA_DIR to a writable temp dir. On the Linux CI runner the path resolved
to a non-writable /app/data, so persistToken silently failed and the
round-trip assertion read null. Import the module dynamically in beforeAll
after setupTestDb, matching the sibling unit test.
* fix: remove unexpected-response listener that blocked pilot reconnect
The ws library skips abortHandshake when an unexpected-response listener
exists, so error and close never fire and the agent hangs in CONNECTING.
Detect auth rejection via the abortHandshake error message instead; the
close handler already performs enroll-token fallback and reconnect.
* feat: move core Blueprint orchestration to Community tier
Blueprints CRUD, reconciliation, and drift modes are now available on
Community. Pin remains Admiral-only via Federation placement controls.
* feat: move Federation placement controls to Community tier
Remove requirePaid from cordon, uncordon, and blueprint pin routes. Ungate the Federation tab and gate cordon UI on node:manage only. Update licensing and fleet docs for the new tier split.
* feat(scheduler): schedule container restart, stop, and start
Add container as a scheduled-task target type so operators can automate lifecycle actions against standalone containers by node and name, with matching UI pickers, validation, execution on local and remote nodes, and tests.
* fix(scheduler): stack service matching and container picker hygiene
Backfill Service on smartFallback containers so per-service stack restarts work when container_name is set. Match services by compose label and container name in stack routes and scheduled restarts. Exclude Sencho from GET /api/containers lists. Hide the Restart Stack service picker when a stack has only one service.
* test(scheduler): scope service checkbox assertion to Services block
The create dialog also has a Delete after run checkbox. Count checkboxes only inside the Services section so CI does not include unrelated form controls.
* fix(scheduler): narrow closest() result to HTMLElement in schedule test
The service-checkbox assertion passed an Element from closest() into
within(), which requires an HTMLElement, failing tsc -b in the frontend
build and Docker build stages. Use the closest<HTMLElement>() type
argument so the value type-checks without an unsafe cast.
* fix(scheduler): hide Sencho container on remote node picker lists
Remote container lists are proxied from peer Sencho instances, so id-only self filtering missed peers on older builds. Await SelfIdentity init, match ImageID, and drop official saelix/sencho images. Apply the same heuristic in the scheduled-operations UI and when the hub fetches remote containers for scheduled runs.
* test(monitor): add missing DatabaseService mocks for scan history cleanup
* test(scheduler): add missing markStaleScansAsFailed mock
SchedulerService.tick() calls db.markStaleScansAsFailed() to sweep stale
vulnerability scans. The scheduler-service test was missing this method in
its DatabaseService mock, causing TypeError failures during test initialization.
Added mockMarkStaleScansAsFailed to hoisted mocks and DatabaseService mock
object, returning safe default of 0 scans marked as failed.
* test(compose): add missing FileSystemService mocks for getStackContent/getEnvContent
* test(containers-route): mock SelfIdentityService to prevent initialize() crash
The excludeSelfContainers() helper calls SelfIdentityService.initialize(), which tries to access DockerController. Without a proper SelfIdentityService mock, the initialize() call fails silently, causing a 500 error on GET /api/containers.
Added SelfIdentityService mock with initialize(), isOwnContainer(), and isOwnImage() methods to prevent the crash.
* feat: add notification suppression rules
* fix: restore label routing and routing test mocks for suppression
* fix: allow bell mute shortcuts for history-only notification categories
Suppression rule validation used the routable category whitelist, which rejected history-only categories such as update_started that appear in the bell during stack updates.
* feat: expand Mute Rules UX with compose-first entry points and activity badges
* fix: add missing NodeContext mocks for notification suppression tests
GitHub Releases appear before docker-publish.yml finishes pushing images.
Probe Docker Hub and GHCR manifests before advertising a version as available.
Sanitize registry probe debug logs for CodeQL log-injection.
Rename "Janitor threshold" to "Reclaimable Docker data threshold" in Settings
and update the unused-Docker-data alert to point users to the Resources view
and the Prune Node Resources scheduled action, which are the real UI surfaces
for reclaiming disk space. Remove "janitor" from settings search keywords.
The internal docker_janitor_gb database key and JANITOR_* constants are
unchanged.
* fix: enforce 1:1 compose path mapping for Pilot agent mounts
Pilot enrollment now generates validated 1:1 bind mounts so every
agent path maps to a unique compose directory. Persisted agent paths
reconcile during startup to catch drift. Unsafe relative-bind redeploys
are blocked before container removal to prevent path escapes.
- Add composePathMapping utility with strict path validation
- Generate COMPOSE_DIR and validated mounts during Pilot enrollment
- Reconcile persisted agent paths during startup bootstrap
- Block redeploy when a relative-bind mount would escape the compose root
- Default Pilot UI path to /opt/docker/sencho
- Update multi-node and pilot-agent documentation
- Add regression tests for enrollment, bootstrap, compose-service,
and environment-check paths
* fix: update E2E enrollment regexes for YAML-quoted token values
The cross-node capability gate cached its verdict, so a remote replaced by older
code at the same URL stayed trusted until the cache expired, reopening the
non-admin HTTP escalation and the over-broad stop. The probe now hits the
remote's live /api/meta on every gated action (concurrent calls deduped, never
cached across requests, fail-closed), so a downgraded remote is detected
immediately.
Two stop-result gaps are also closed:
- A remote stop result must now cover exactly the confirmed stacks (one per
stack, no extras, no omissions), not merely exclude extras, so a dropped
confirmed stack is no longer accepted as clean. runLocalLabelStop reports one
result per confirmed stack even when the label has vanished, so a current
remote always satisfies the check.
- The local stop exception path now reports the full confirmed set, so a
confirmed stack that lost its label is not dropped when the local stop throws.
An older remote node ignores the forwarded actor-role header (running proxied
requests as admin) and ignores the stop-by-label stack allowlist (stopping
every label-matched stack). The control could neither detect nor prevent this
on a mixed-version fleet.
Instances now advertise a cross-node-rbac capability, and the control refuses
to act when a remote lacks it:
- HTTP proxy: a non-admin user's request is not forwarded to a remote that does
not advertise the capability (fails closed when it cannot be determined).
Admins are unaffected.
- Stop-by-label: a real stop bound to a confirmed stack set is not sent to a
remote lacking the capability; the node is reported as needing an upgrade. As
defense in depth, a node whose results name stacks outside the confirmed set
is failed rather than rendered as a clean stop.
Separately, the stop's lock-contention path now reports every confirmed stack
as a contention failure (including one that lost its label), so a confirmed
stack is never silently dropped and the result is never empty.
A remote WebSocket upgrade was forwarded to the target node before any role
check, and the forwarder authenticates the connection to the remote as an
admin-gated console_session. A non-admin could therefore open a remote
container-exec or host-console socket that the local handlers reject.
The hub now applies the same gate before forwarding: logs and notifications
stay open to any authenticated user, every other path (container exec, host
console) is admin-only, and machine node_proxy tokens are rejected on the
interactive paths. This covers proxy-mode and pilot-agent remotes, which share
the forward path.
The fleet overview, configuration, dependency-map, and networking-summary reads
were authentication-only, so a role without node:read (deployer) could read node
names, host stats, and cross-node topology. They now require node:read, matching
the role model where every role except deployer holds it.
For parity, the Fleet nav entry is gated on node:read (hiding it from the top
nav, mobile menu, and command palette), the Fleet view redirects to the
dashboard when reached without it, and the dashboard fleet heartbeat falls back
to the single-node restart map for a role that cannot read fleet data.
A fleet stop re-matched stacks by label name at execution, so a stack that
gained the label between the operator's preview and confirmation could be
stopped even though it never appeared in the confirmation. A confirmed node
that was deleted after the preview also vanished from the results, letting the
remaining successes read as a clean stop.
The confirm flow now sends the exact node and stack list resolved in the
preview. Each node's stop is bound to that set: only stacks that are still
label-matched and confirmed are stopped, and a confirmed node missing from the
registry is reported as an explicit failure rather than dropped.
Proxied requests authenticated to a remote node previously ran as admin
regardless of the originating user's role, so a non-admin using the UI
against a remote node could reach admin-only handlers there.
The forwarding primary now asserts the user's role on a trusted header
that the remote honors only for node_proxy/pilot_tunnel bearers (the same
trust model as the license tier header), and the gateway overwrites the
header on every proxied request so a client cannot smuggle it. An absent
header keeps admin for direct instance-to-instance and background service
calls; an unrecognized role fails closed to read-only.
Scan policies, deploy enforcement, the suppression-aware deploy-block
toggle, SARIF export, and OpenVEX export now work on Community, matching
the rest of the vulnerability-scanning surface that was already free.
Backend: drop the tier gate from the seven security routes and from the
dashboard configuration-status scan-policies row, so the Dashboard and
Fleet config cards stop hiding the Vulnerability scanning row. Reading
policies stays auth-only; mutations and exports stay admin-only.
Frontend: always show the Policies tab and panel, the SARIF and VEX
export actions, and the honor-suppressions toggle for admins.
Docs: move scan policies, SARIF, and OpenVEX to every tier across the
feature and API-reference pages; clarify that Fleet Sync's cross-node
replication remains the paid part.
* fix: harden cross-node fleet label actions and guard container reads
Release-stabilization fixes for the Fleet Actions surface:
- Stop-by-label binds execution to the nodes shown in the confirmed
preview. The real stop sends the confirmed node ids and the backend
restricts the fan-out to them, so a node that was unreachable during
preview and reconnects before the stop can no longer enter execution
and have unlisted stacks stopped.
- Bulk label assign validates each remote node's result against the
stacks it was asked to label: a body whose results are empty, partial,
duplicated, or shaped wrong is a per-node failure instead of reading as
a successful zero-stack assign. The card mirrors this, rejecting a
missing or non-array results body and only reporting success when at
least one stack was assigned.
- Bulk label assign re-reads authoritative per-node stacks and labels on
demand via a Refresh control, and the confirmation lists the affected
node and stack names rather than bare counts.
- The stack-specific and fleet container/stack read routes require the
stack:read permission, matching the generic container and stack routes.
Every shipped role already carries stack:read, so reachability is
unchanged; the guard closes the routes that were auth-only.
Adds unit coverage for the assign-result validator, route coverage for
the stop allowlist and assign membership checks, and authorization
coverage for the newly guarded reads.
* test: assert the confirmed node allowlist in the fleet stop-card test
The stop-card component test pinned the real-stop request body to
{ labelName, dryRun } and broke once the stop began carrying the
confirmed-preview node ids. Update it to expect the nodeIds allowlist
derived from the resolved preview, so the test asserts the binding
rather than the pre-fix shape.
Security page UX fixes:
- Stop the CVSS x EPSS scatter chart from painting a full-plot "white
rectangle" cursor on click (cursor disabled), and prevent click-drag
selection on charts.
- Differentiate the overview action links: "fixable" links (masthead primary
action, review-queue blocker, and the Fixable signal tile) now open the
Images tab pre-filtered to fixable findings; the Stale and Failed signal
tiles link to the History tab where those scans are listed; Secrets and
Misconfigs tiles link to their tabs. The Images tab accepts an initialFilter
and exposes a Fixable option in the severity dropdown.
- Fix the "Secrets / misconfigs" option wrapping and misaligning in the
severity dropdown (single-line option labels, wider trigger).
- Add Edit for CVE suppressions and misconfig acknowledgements (reason, scope
pattern, expiry), reusing the existing dialog and the existing PUT endpoints;
the CVE/rule identity stays fixed.
PR #1497 pinned an enabled one-shot's next_run_at on creation, but the chosen
instant did not survive two paths: editing reconstructed the date from the
yearless cron (current year), and a disabled one-shot nulled next_run_at with no
other store, so enabling it later recomputed from the cron. Both moved a
future-year one-shot to a different annual occurrence than the date displayed.
Persist the one-shot's absolute fire time in a dedicated run_at column
(additive, nullable; recurring schedules leave it null). Create and update store
run_at independently of the enabled state; next_run_at is derived from it when
enabled and null while disabled, so a disabled one-shot keeps its run_at and the
enable toggle restores the exact instant from the column rather than the cron.
The editor reconstructs a one-shot's date from the persisted run_at, so opening
and re-saving without changes preserves the originally chosen year.
No behavior change for recurring schedules or fresh installs; the column is added
by an additive migration safe for upgrades from v0.92.0.
Make the Resources Images, Volumes, and Networks tables sortable with a
shared useTableSort hook and SortableTableHead, move the Images scan-history
control into the Images tab header, and keep the network List/Topology toggle
anchored with Create Network visible in both modes.
Rework the dashboard Stack health table: drop the redundant Host column, add
sortable Stack/Up/CPU/Mem headers, and add Source (local/git) and Port columns.
The status endpoint now labels each stack with its git/local source, computed
outside the cache so linking changes show immediately.
Extract a reusable CreateNetworkDialog and add a create-network action to the
stack-detail Networking tab.
Simple "Once" schedules compiled to a 5-field cron, which has no year field, so
the scheduler computed the next run as the next annual occurrence. A date chosen
for a later year ran a year early, and a time already elapsed today ran a year
late, contradicting the UI promise that the task fires on the chosen date.
One-time schedules now send the chosen absolute timestamp (run_at) and the
backend pins next_run_at to it instead of the cron-derived next run, so the run
fires on the exact selected instant including the year. The Simple-mode
validation now compares the full chosen instant against the current time, so a
time earlier today is rejected as past rather than silently deferred a year.
run_at is validated as a finite, future epoch-millisecond timestamp on create
and update. The enable/disable toggle preserves a one-shot's pinned next_run_at
(its yearless cron cannot reconstruct the chosen year), so re-enabling restores
the exact instant. Recurring shapes and Advanced mode are unchanged (cron stays
authoritative).
The changelog tab fetched release notes once and held them in component state
without tying them to a version, and the endpoint did not report which release
the notes belonged to. When a newer release surfaced while the sheet stayed
mounted, reopening the changelog could show the previous version's notes, and a
GitHub/Docker Hub fallback or independent cache timing could leave the notes out
of sync with the advertised latest version.
The release-notes endpoint now returns the release version (normalized
tag_name). The changelog keys its loaded notes to the advertised latest version,
refetching when that version changes, and labels the notes with the version they
belong to so the displayed content is always explicit.
In cron mode the service armed the first check for two minutes after boot
regardless of the configured schedule, so a restart triggered an out-of-cadence
check (a weekly cron would run on every boot, then follow cron). Arm the first
check at the next cron fire time instead; interval mode keeps its 2-minute
post-boot delay.
A previous fix canonicalized each Sencho-managed path (compose base, data dir,
application root, OS temp root, upload spool, Trivy binary, Trivy cache) with
fs.realpath so a managed path that is itself a symlink could not be reached through
a bind to its real target. fs.realpath returns ENOENT for a dangling symlink whose
final leaf does not exist yet, so that case fell through to the configured path only
and the implied target was lost. With TRIVY_BIN pointing at a symlink to a not yet
created binary, a bind to the symlink target's existing parent directory was still
classified as browsable and writable. A stack editor could create the leaf there
and have a later pre or post deploy scan execute the attacker supplied binary, which
the default image runs as root. The same gap applied to dangling symlinked upload
spool, Trivy cache, and OS temp roots.
Resolve managed paths with a helper that follows the symlink chain even when the
final leaf is absent: it follows a dangling link through readlink (resolving a
relative target against the link's own directory) and, for a missing leaf, resolves
the longest existing ancestor and re-appends the absent suffix, so the real target
location is always represented in the overlap set. A bounded hop count guards
against a symlink cycle that never surfaces as ELOOP, falling back to the lexical
path with a log. An unexpected resolution failure (EACCES, ELOOP) keeps the
configured path as the containment anchor, continues discovery for the other
managed paths, and is logged.
Tests: a mock-based regression test that fails before the fix, a real-filesystem
dangling-symlink test on Linux and macOS CI, a relative-symlink-target case, and a
non-ENOENT failure case asserting discovery degrades safely.
The file-explorer overlap check canonicalized declared bind sources (it realpaths
the source) but compared them against the Sencho-managed paths (compose base, data
dir, application root, OS temp root, upload spool, Trivy binary, Trivy cache) using
path.resolve only. A managed path that is itself a symlink, for example a relocated
Trivy binary whose configured path links to a real binary elsewhere, was therefore
compared by its symlink path. A bind to the symlink target's real directory did not
register as a managed overlap and became browsable and writable, so a stack editor
could overwrite the real binary a later pre or post deploy scan executes, or reach
transient registry credentials under a symlinked temporary root.
Resolve each managed path to its canonical target and keep both the configured path
and the realpath target in the overlap set, mirroring the bind-side canonicalization.
Containment only ever expands, so existing deployments and fresh installs are
unaffected. A missing managed path (the common fresh-install case) is tolerated: the
configured path still anchors containment, and any non-ENOENT realpath failure is
logged rather than collapsing discovery.
Adds two tests: a regression test that fails when a symlinked managed Trivy path is
compared without canonicalization, and a fresh-install test that an absent managed
path leaves a legitimate external bind browsable.
The scan-detail banner only recomputed its verdict when honor-suppressions was
enabled; otherwise it returned the snapshot stored at scan time. The deploy gate
always re-evaluates current policies, so with honor-suppressions off a policy
lifecycle change drifted the banner from the gate: disabling (or editing) an
enabled policy left the banner claiming a violation the gate would now pass, and
tightening a passing policy left the banner reporting a pass the gate would block.
Recompute the banner verdict unconditionally so it agrees with the gate across
the full policy and suppression lifecycle, regardless of the honor-suppressions
setting. The recompute already reads that setting itself (so a raw or
suppression-filtered verdict is chosen correctly), returns no verdict when no
policy matches (clearing the banner to match a passing gate), stays read-only,
and still falls back to the stored snapshot if it throws.
The file-explorer root containment treated the OS temp root as an ordinary
host path, so a stack author with stack:edit could declare it (for example
/tmp) as a bind source and browse it. Sencho writes short-lived secrets there:
ComposeService and TrivyService stage a docker config.json holding resolved
registry credentials, uploads spool under it, and compose/git/scan runs create
working dirs there. Exposing that directory is a credible path to read
admin-configured registry credentials during a pull or deploy.
The same gap left env-relocatable tool paths outside containment: a Trivy
binary placed at a custom TRIVY_BIN (for example under /opt, which is otherwise
allowed) could be overwritten through a bind and then executed by a privileged
pre-deploy scan.
Treat the OS temp root and the configurable upload spool, Trivy binary, and
Trivy cache as Sencho-managed areas, so a bind overlapping any of them (in
either direction) is never browsable, writable, or chmodable. The managed Trivy
install and cache already sit under the data dir and stay covered. Legitimate
external binds outside these areas remain fully editable.
The exploit-intel query capped its results with "EPSS DESC, then CVSS DESC",
treating an absent EPSS score as the lowest possible value. The overview list
ranks the same findings under the assume-it-is-automatable model, where a
finding with no EPSS evidence outranks one shown unlikely to be exploited
(known-exploited > elevated EPSS > unknown EPSS > known-low EPSS).
With more than the row cap of findings, the two disagreed: the cap could keep a
known-low-EPSS finding and drop a higher-CVSS finding with no EPSS evidence that
the list considers more urgent, hiding it from the dashboard. Rank the capped
query by the same tiers the list uses (reusing the shared EPSS threshold) so the
rows that survive truncation are the ones the list ranks highest.
The policy verdict shown on the scan-detail banner was the snapshot computed
once at scan time. When the deploy gate is set to honor suppressions, the gate
re-reads current suppressions on every deploy, but the stored verdict never
changed: after creating a matching suppression the banner kept claiming a
violation the gate would now pass, and after deleting or expiring a suppression
the banner kept reporting "ok" while the gate would block.
Recompute the banner verdict against current suppressions when serving the scan
detail, so it agrees with the gate across the create, update, delete, and expire
lifecycle. The recompute runs only when honor-suppressions is enabled (otherwise
suppressions affect neither the gate nor the verdict, and the stored snapshot is
returned unchanged), is read-only, and falls back to the stored snapshot if it
fails so an informational banner can never fail the scan-detail request.
The file-explorer root containment treated only kernel and OS-state paths
(/etc, /proc, /sys, /dev, /run, /var/run) as dangerous. System locations
that hold the executables and libraries Sencho's own runtime depends on,
notably /usr (which contains /usr/local/bin/node, the docker CLI, and the
entrypoint) plus /bin, /sbin, /lib, /lib64, /boot and /root, were left
browsable, writable and chmodable.
A stack author with stack:edit could declare one of these as a bind source,
overwrite a binary, and have a later deploy execute it. Add those locations
to the dangerous-root set so such a bind is never browsable or editable; the
boundary check still permits ordinary host paths whose name merely prefixes a
protected root (for example /usrdata).
The Security overview's top exploit-risk list is built from a query capped at
2000 rows. The query had no ORDER BY, so when a node had more findings than the
cap the rows kept were arbitrary: the list could rank and display a subset that
omitted higher-risk findings, and the frontend discarded the truncated flag the
endpoint already returned, so nothing told the operator the list was partial.
- The query now orders by known-exploited, then EPSS, then CVSS before the cap,
so the rows that survive truncation are the highest-risk ones, matching the
client-side ranking the list applies.
- SecurityView keeps the truncated flag and threads it through to the list,
which now shows a short "more exist than can be listed here" note when the set
was capped.
Also fixes a presentation regression: the list colored every non-Critical
severity dot with the High color, so a Medium or Low known-exploited finding
(now surfaced alongside Critical/High) showed as High. The dot now maps to the
finding's actual severity.
The pre-deploy gate filters suppressed findings when the honor-suppressions
setting is enabled, but the informational evaluation that drives the scan banner
and the scheduled-scan alert always scored the raw findings. A finding that was
fully suppressed therefore showed a policy violation on the banner even though
the gate would let the deploy through, so the two surfaces disagreed.
evaluateScanAgainstPolicies now mirrors the gate: when honor-suppressions is on
it loads the detail rows and drops suppressed findings before scoring (for the
severity input too, matching how the gate forces the detail path in that mode),
so the banner and alert agree with the gate. With the setting off, both continue
to score the raw findings. The truncation fail-closed rule stays gate-only; the
gate remains authoritative for blocking.
The Files & Volumes explorer derives its browsable roots from a stack's declared
bind mounts. A bind whose source resolves inside Sencho's own application root
(the working directory the image runs from, holding the compiled dist/, the
served public/, and node_modules) was classified accessible, browsable, and
writable. A non-admin with stack edit rights could therefore declare a bind such
as /app/dist into a stack and gain read/write access to Sencho's program files.
The bind-root classifier now treats the application root as a managed area, the
same way it already treats the compose base and the data directory, so a bind
that overlaps it is non-browsable and non-writable and the file routes reject
read and write operations against it. The check is gated on the bind not being
inside the current stack directory, so a legitimate stack-scoped bind under the
compose base (which can sit under the application root) stays browsable.
The application root is resolved dynamically from the process working directory,
mirroring how the data directory is resolved, rather than hardcoding a path.
* fix: request registry tokens with the target repository scope
The image-update detector authenticated to registries by reusing the scope
echoed in the registry's GET /v2/ ping. That ping carries no repository
context, and ghcr.io answers it with a placeholder scope
(repository:user/image:pull), so the token was requested for the wrong
repository and rejected. Every ghcr.io-backed image (including lscr.io, which
delegates auth to ghcr.io) then failed its manifest lookup and was reported as
"Registry unreachable", while Docker Hub and quay.io kept working. Always
request a pull scope for the repository being checked rather than the echoed
placeholder.
Also report the actual failure cause: getRemoteDigestResult now distinguishes
an authentication failure, a rate limit (with retry-after), a missing image, a
registry error, and a genuinely unreachable registry, instead of collapsing
every failure into "Registry unreachable". getRemoteDigest stays a
digest-or-null wrapper so the update-preview path is unchanged, and
listRegistryTags shares the same token path so it now resolves on
ghcr.io/lscr.io too.
* fix: neutralize control characters in the registry digest error log
The error-path console.error in getRemoteDigestResult interpolated the image
ref and the caught error message, both of which originate from compose-authored
input. Route them through sanitizeForLog so a crafted image string or upstream
error text cannot forge multi-line log entries (log injection). The returned
reason and the digest logic are unchanged.