Skip to content

Release/2026.14.0 - #26

Merged
ConnorHaggerty merged 153 commits into
mainfrom
release/2026.14.0
Oct 5, 2026
Merged

ConnorHaggerty merged 153 commits into
mainfrom
release/2026.14.0

Conversation

@ConnorHaggerty

@ConnorHaggerty ConnorHaggerty commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

Summary

Type

  • Bug fix
  • Feature
  • Refactor / cleanup
  • Docs

Proposed commit message

2026.14.0. Added docker management app updates app policies and vulnerability screen management connected to apps and groundwork for  Anvil

Checklist

  • Tests pass: cd server && USE_SQLITE=true VIGIL_SIGNING_KEY_SEED=<seed> .venv/bin/python manage.py test
  • Migrations added if any model changed (makemigrations)
  • If this is a release, version bumped in lockstep: settings.py (VIGIL_VERSION) and agent/vigil_agent/__version__.py — those two only; the expected agent version is detected, not configured
  • No edition (Pro / Enterprise) code added to this Community repo
  • Extension contract intact (docs/pro-extension-points.md): KNOWN_EVENTS stable, no edition imports in core
  • No AI / Co-Authored-By attribution in commits
  • No secrets, keys, or signing seeds committed

Testing

Screenshots

VIGIL_TOKEN=x curl ... | sudo bash put the variable in curl's environment
only; the piped bash never saw it and sudo resets the environment anyway,
so install.sh generated a random token and the wizard waited forever.
Pipe through env with VIGIL_TOKEN set, run as root (sudoers policy can
refuse VAR=value arguments, but env as the command always works), correct
the same wrong usage in install.sh's header comment, and pin the command
shape with a test that executes it under bash with a fake download.

Mutation-verified: restoring the old line makes
test_linux_command_delivers_token_to_the_installer fail (stdout empty).
… restart running agent

Real-VM enrolment exposed three installer bugs: mkdir inherited the
0027 umask (0750) so the monitor-mode vigil-agent user could not
traverse into /etc/vigil to read the agent.yml it owns; VIGIL_TOKEN
was only consulted when agent.yml was absent, so re-adding a machine
silently kept the first install's token the server no longer waits for;
and systemctl start is a no-op on a running unit, so even a corrected
token would never be picked up.

Fixes A (chmod 0755), B (elif branch in both sh and ps1 replacing only
the agent_token line, preserving CRLF on the Windows side), and C
(restart instead of start). New InstallerReinstall tests exercise the
rendered scripts, including a real sed run against a temp config.
A Windows monitor-mode agent (NT SERVICE\vigil-agent, no PDH access) lost
every cpu/memory point to one raising sub-call, and both platforms reported
read-only image mounts (snaps, CD-ROM) as 100% disks.

- collect_cpu/collect_memory: a failing getloadavg/swap_memory now degrades
  to the sub-metric with a once-per-process warning instead of discarding the
  whole collector's output.
- disk collection and _read_disks skip squashfs/iso9660/udf/cdfs and
  cdrom-opted partitions, which are 100% by construction.
- agent_install.ps1 monitor mode adds the service's virtual account to
  Performance Monitor Users (S-1-5-32-558) so load/swap counters open after
  the service restart.
- vigil-nav.js: a .tab-bar outside any .page (the Add Host modal's OS toggle)
  no longer throws on click.
The agent's token was its only identity, so re-enrolling a machine created a
second host record. The agent now reports a stable per-machine id (systemd
machine-id, Windows MachineGuid, macOS IOPlatformUUID) via _system_info() on
register and checkin; the server stores it on Host.machine_id and backfills
existing rows. No enrolment behaviour changes — phase 03 uses the id to
replace the old record at approval time.
A host's identity was its agent token, so reinstalling the agent on a machine
Vigil already knew created a second record: the device showed twice and each row
approved separately. Approval now matches the pending row against an approved
record with the same machine_id and moves the machine onto it, keeping the id,
tags, site assignment and metric history, then clears any other pending row for
that machine. A first enrolment, and any host whose platform reports no
fingerprint, behave exactly as before.

The replacement stays behind the TOTP-gated approval rather than happening at
registration, because /api/v1/register is unauthenticated and a fingerprint is
not a secret.
Phase 03 moved a re-enrolling machine onto its existing record at
approval time, but nothing told the admin that is what would happen.
The wizard's detected-agent step now reads the replaces key from
check_pending and shows the replaced hostname (textContent, since the
agent supplies it), and each row in the enrolment queue is annotated
with the record it will adopt. A first enrolment renders no
annotation either surface.

The queue is server-rendered, so the dashboard view materialises the
pending rows and stamps each with its replacement candidate;
pending_count follows from the same materialised list.
The audit API was already gated behind the Business audit_log feature,
but the admin registered AuditEvent with search, filters and a date
hierarchy and never asked, so any Free superuser could read the whole
trail at /admin/. The admin now checks the licence on every request:
view permission and the module's place on the admin index both require
audit_log. It is checked per request rather than at registration
because a licence is applied or lapses while the process runs.

A licensed admin sees exactly what they did before, and the trail stays
append-only. Against a running Free instance the Audits section is gone
from /admin/ and the changelist answers 403 (it served 34 events before).

Server 1653 tests.
With no ruff config in the repo, ruff decides what is first-party
from the directory it runs in. Run from server/, `from vigil import x`
sorts into its own block; run from the repo root, the same import
sorts in with django. The two disagree on every file that imports
vigil or apps, so one of them always reports I001. Rooting src at
server/ makes both agree with the style the code already uses. No
rules change: ruff check server/ reports the same 576 findings with
and without it.
Every active user used to be a seat, so a read-only viewer cost the same
as the admin who runs the install. A seat is now a technician: a
superuser, a staff user, or a profile holding Admin or Operator. Viewers
are free and unlimited on both tiers. Seat overage is still only a
banner; creating an account is never blocked by it.

The Free tier's real limit is two Admins. Granting the Admin role, at
creation or by promotion, now answers 402 with the standard upgrade body
once two Admins exist, and a refused creation leaves no half-made user
behind. Existing Admins past the cap keep working, re-applying Admin to
an Admin is a no-op, Viewer creation is untouched, and Business has no
cap. role_of() calls a superuser "owner", so both owner and admin count
toward it.

Against a running Free instance: a second Admin was created (201), a
third was refused (402, no user row), a viewer was created (201) and
refused promotion to Admin (402), and seats_used went 1 -> 2 with the
viewer not counted.

Server 1664 tests.
A licence blob had no identifier of its own, so a key posted publicly
could not be traced back to whoever bought it. Blobs may now carry a
lid claim. It is optional: every key issued before it has no lid and
still verifies exactly as before, with an empty id. The licence API
reports it as license_id (null when unlicensed), and `manage.py
license` prints a "license id:" line only when the blob has one.

Business licences now grant jackil, the bundled ticketing, so the Jackil
side can verify the same blob. editions.py builds its tiers from
BUSINESS_FEATURES and needed no change. The sso feature is pinned to
mean external identity providers (SAML/OIDC) only; Civil SSO is free on
both tiers and never consults it, and a test keeps it that way.

With a throwaway signing key and real signed blobs, the license CLI and
API showed: a blob without lid is valid with no id line and
license_id ''; a blob with lid LIC-E2E-0001 prints and reports it; both
grant jackil and sso; after `license clear`, license_id is null and
jackil is off. Making lid a required claim turns the no-lid blob
invalid and fails the compatibility test.

Server 1671 tests.
CONTRIBUTING never said what licence a contribution arrives under. It
now does: inbound Apache-2.0 to Susquehanna Syntax, which is what lets
the same code ship inside the commercial edition, with a DCO sign-off
on every commit and contributors keeping their copyright. The PR
checklist asks for the sign-off and for Business code to stay behind
has_feature() gates.

The docs and docstrings still described Vigil-Pro and Vigil-Enterprise
as separate private repos. Business code has lived in
server/apps_business/ in this repo for a while, so CONTRIBUTING, the PR
template, the hooks module, the extension URL comment and the example
extension now say so, in the wording editions.py already uses. The
README gains a short Editions section linking docs/EDITIONS.md.

A wording test keeps the retired phrasing out of those files; putting
"Pro / Enterprise" back in the urls.py comment turns it red.

Server 1675 tests.
One action, both container shapes: the container's own labels and
Config.Image decide whether the update goes through compose (pull +
up -d --no-deps against the labelled compose file) or a plain pull +
_recreate_container. The image reference (never the top-level image ID)
is what gets pulled, and digest-pinned containers are left alone.

The server cannot know how a container is managed, so this lives on the
agent. Declared standard-risk with only container_name required; the
old UI-side _staticDockerFix guessed the image ref and compose path
and got both wrong (M2 phase 02 removes it from the UI).

Forced by the suite: update_container added to the wiki container group
and wiki/vigil-wiki.html regenerated.
After a pull, compare the image id the reference now points at with the
container's current image id. Equal means the container already runs the
newest build, so recreating it would only restart a running container for
nothing; report via pull instead.
…ve AI Docker suggestion

Give the UI the three server pieces for a one-click container update:

- new builtin template update-container.yaml (input: container_name,
  action type update_container) plus the 0022 seed migration that
  upserts it like 0010 does;
- DockerContainerSerializer gains an 'outdated' flag, true while the
  container's outdated-image alert is FIRING or ACKNOWLEDGED — both
  container views pass the open (host, container) set as serializer
  context so a list costs one query;
- the AI container suggestion URL, view, and prompt helper are removed
  (an image update needs no model); alert and vuln paths untouched.

Tests cover the seed, input resolution, all three alert states, the
overview endpoint, and the 404/NoReverseMatch on the removed endpoint.
…docker fix

A container whose image is outdated now offers an Update button in the host's
container table. It opens the normal deploy modal on the built-in
'Update container' task with the host and container name prefilled, so the
update goes through the same TOTP approval and signing as every other task.
The AI 'Suggest fix' path for containers (suggestFixForContainer,
_staticDockerFix) is deleted; the alert and vulnerability suggestion flows are
untouched. openDeployModal accepts an optional prefill object; existing
one-argument callers are unchanged.
The old {{ inputs.x }} marker collided with bash/PowerShell/YAML/Docker
brace text inside scripts. The new ${{ inputs.x }} marker cannot be
mistaken for script text in any of those languages, and the legacy form
still resolves but collects a save-time deprecation warning in
parsed_spec["warnings"] for one release. ${{ steps.… }} references
are rejected with a clear message until step outputs exist.

Mutation-verified: dropping the (?<!\$) lookbehind from
_LEGACY_INPUT_REF turns test_new_marker_does_not_warn red (the legacy
pattern re-matches the inner part of every new-style marker).
The runtime re-templated every {{…}} it found and blanked unknown names,
so docker ps --format '{{.Names}}' ran as docker ps --format ''. Narrow
_TEMPLATE_RE to the ${{ … }} marker, seed ctx['inputs'] so ${{ inputs.x }}
resolves like the flat name, and update the module docstring to the new
syntax. Bare braces (Go templates, find -exec {} \;, PowerShell blocks)
reach the action handler exactly as signed.
One-time data migration (tasks.0023) rewrites every stored occurrence of
the deprecated bare-brace input marker: TaskDefinition.yaml_source and
parsed_spec (including clearing the old-syntax warnings), plus
PlaybookStep and Automation params_override overrides. Backward restores
the old form for rollbacks. The marker regexes are copied from
apps.tasks.spec so the migration keeps its meaning if spec.py changes.

The playbooks app's label is 'baselines' (pinned in apps.py), so the
migration depends on and fetches that label, not 'playbooks'.

update-container.yaml moves to the new marker; fresh installs seed it
through the live parser, which accepts both forms.
A value pasted into a command line is code; in an environment variable it
is only data. execute_script now receives the resolved task inputs and
maps them through _input_env into VIGIL_INPUT_<NAME> variables (bools as
true/false, integral floats as ints, names validated, NUL bytes refused),
so an input carrying a command-injection attempt can no longer become a
command.

_run gains extra_env (applied last via clean_env); execute_action gains
keyword-only inputs forwarded only to execute_script; TaskRuntime passes
ctx['inputs'] through.
…arnings

The task editor's starter templates are what an author copies when writing
a new task, so they must teach the new marker syntax. Every one of the 13
{{ inputs.x }} occurrences is written ${{ inputs.x }} inside the backtick
string — an unescaped ${ would start a JS interpolation and break the whole
file (and with it the Tasks page).

renderEditorPreview now also renders the validator's spec.warnings (phase
01) below the preview-meta row, each line through escHtml, so an author
typing the old form sees 'old input syntax — write ${{ inputs.x }}' while
editing. .editor-warnings is styled with existing tokens only.
…EADME, wiki

M3 phase 05b: every surface that generates or teaches task YAML now uses
the ${{ inputs.x }} marker and block-style steps. The AI SYSTEM_PROMPT
teaches - id:/type:/params: blocks and VIGIL_INPUT_<NAME> for scripts;
the README example and the wiki (generator + regenerated block + two
hand-written lines) use the new marker. New test_docs_marker pins prompt,
README and wiki to the new syntax so the old {{ inputs.x }} marker cannot
creep back.
…ged hosts

execute_script now takes either a script_name (a file in scripts_dir, as
before) or an inline shell + script body. A full_control host runs any body.
A managed host runs one only if its sha256 is listed in
allowed_script_hashes in agent.yml; otherwise it refuses with
"script hash not allowlisted: sha256:<hex>" and says how to approve it.
Any edit changes the hash, so every change is re-approved on the host.

scripthash.py is the single definition of a body's identity (CRLF to LF,
one trailing newline, sha256) so the agent, the editor and the allow-script
CLI agree. The body runs from a private temp dir that is always removed,
with inputs as VIGIL_INPUT_* env vars, and the output names the hash that ran.
execute_script now takes script_name or an inline shell + script body.
The validator requires exactly one, a known shell with a body, and refuses
${{ }} markers inside a body (a script reads inputs as VIGIL_INPUT_* env
vars). A body is never reference-checked or substituted, so it is the same
text, and the same hash, on every deploy; the parsed action carries its
script_sha256 so the editor can show the value a host owner must approve.

scripthash.py is a byte-identical copy of the agent's, pinned by a sync
test like expression.py. The wiki action reference is regenerated.
…approval

An inline script's approval is per-contents (its sha256: hash on a managed
host), so the editor must show the hash the host owner will approve — not the
body, which would be unreadable on one line and not what the agent compares
against. The docs spell out that every edit changes the hash and must be
re-approved, and how to approve with vigil-agent allow-script or by hand.
…-script hashes

A signed task that could add hashes would let the server approve its own
code (full_control again), so hash approval is a local admin command that
edits agent.yml as text — comments survive, flow style is refused, mode is
preserved, and the agent is restarted afterwards.
…line strip

Commit 33935ef added .replace(/\n+$/, '') to the preview's line count so a
trailing newline doesn't add a phantom line, but this phase-06c test still
asserted the pre-strip literal, so the server gate has been red since.
Update the asserted literal to match the shipped JS.
…save time

check_service, update_container, check_docker_updates, run_command and
execute_script now declare their outputs in ACTION_REGISTRY, and every step
has an implicit status. A later step may use ${{ steps.<id>.status }} or
${{ steps.<id>.result.<field> }} in its params, and when: may read
steps.<id>.status / steps.<id>.result.<field>. The validator rejects a
reference to a step that does not run earlier, to an output the action does
not declare, or in any other shape: the value only exists on the host at run
time, so a bad reference would otherwise resolve to nothing on every run.

expression.py (both byte-identical copies) gains the steps root, allowed three
levels deep, and referenced_steps(). The server never substitutes step
references.
…asks as fixes

Every task now says where it came from: Organization (written by your own
team, the default), Community (taken from the catalogue — same review and
signing) or Vendor (made from a vendor's findings, such as a fix generated
for an Anvil finding). The library shows the tag, and a detection task
shows its severity; a new Detection tasks only filter lists just those.

A detection task joins the remediation view as a fix. Findings a detection
task raised are fixed by deploying that task again, and a group with no
package fix — a Log4Shell jar on disk, say — now offers a detection task in
the library that names one of its CVEs, with every affected host selected.
Only tasks with a relevant: block and a severity count, and archived ones
never do.
…he vulnerability docs

The wiki gains Detection tasks: the three keys (severity, cves, boost),
the Log4Shell example — parsed straight out of the page by a test so it
cannot stop validating — what happens on each host (not applicable closes
an earlier finding, a failed fix leaves it open, a completed one records it
found and fixed), and the Organization / Community / Vendor tags. The
vulnerability section explains importing an Anvil record: schema-checked,
false positives skipped, pinned to a host or matched by name.

The assistant learns the detection keys, and EDITIONS.md lists them as
Free.
… at check-in

Container actions need more than the read-only GET the inventory used. The
agent now has an Engine API client over the Unix socket: GET, POST and
DELETE, errors that carry the engine's own message and status, /_ping to
see that an engine answers, and /version to name it — Docker or Podman —
and pin requests to the lower of its API version and the newest this agent
is written against, so a newer engine is spoken to in a dialect we know.

It finds sockets the way an operator would expect: $DOCKER_HOST (unix
only), /var/run/docker.sock, rootful Podman's /run/podman/podman.sock, then
each user's rootless Podman socket, which stays that user's. Every ten
minutes the agent reports the engines it found at check-in — kind,
version, API version, rootless and its uid — and the server keeps them on
the host. No engine means no key, and the server keeps what it had.
… socket

A monitor-mode agent runs unprivileged, and the engine socket is root's;
the docker group is root by another name. So the installer, when it finds
Docker or rootful Podman on a monitor-mode host, adds vigil-engine-proxy:
the same binary run as root with --engine-proxy, listening on
/run/vigil/engine-ro.sock (0600, owned by vigil-agent). It forwards a GET of
the container list, one container's inspect, a single stats sample, logs
without follow, the image list, and the engine's ping and version. Every
POST and DELETE, exec, archive, volume and network call is answered 403
and never reaches the engine; streaming calls are refused so the proxy
cannot be held open.

An unprivileged agent looks for the proxy's socket first. AGENT-PRIVILEGES
gains a Containers section: managed agents open the engine socket as root,
rootless Podman is acted on as its user, monitor agents go through the
proxy, and no UI path hands anyone the socket.
… CLI

Every single-container action — restart, stop, start, pull, remove,
recreate, update, clear logs — now goes through the Engine API client
instead of shelling out to docker. Same action names, same params, same
outputs; but it works on a Podman host with no docker CLI at all, on
rootless sockets, and errors carry the engine's own message.

Recreating a container builds an Engine API create body instead of a
docker run argv: only what the user set is carried over (env, labels,
command and entrypoint diffed against the old image's defaults; ports,
binds and volumes, network, restart policy, capabilities, devices), so the
new image's own defaults still apply. A failed create still puts the
original back under its name and starts it. A pull reads the engine's
progress stream, because the engine reports a failed pull inside a 200.

Stacks still go through the compose CLI — docker compose, or podman
compose where there is no docker — pointed at the same socket with
DOCKER_HOST.
…ainer runs

The agent's container snapshot now comes from the engine client — so it
works on Podman and through monitor mode's read-only socket — and carries
what stack management needs: the compose project's config files and
working directory, compose's config hash (a change is drift), each
container's restart policy, and its image's repo digest (what a rollback
pins to). One image inspect serves every container on that image.

The server keeps a ContainerStack per compose project per host, found
from those labels at each check-in: its config files, working directory,
engine, how many containers it has and how many run, and its ownership —
external when merely seen, adopted or managed once Vigil takes charge. An
external stack whose containers are gone is dropped; a managed or adopted
one stays, because stopped is not gone. GET /api/v1/hosts/<id>/stacks/
serves them, site-scoped.
…the host page

Each host's Containers section now shows every compose stack with who owns
it (external, adopted, managed), its engine, and its compose files on
hover, plus Restart and Update buttons for the stack; each container has
Restart and Stop, or Start when it is not running, beside the existing
Update. Every button opens one of the built-in tasks in the normal deploy
dialog with the container or stack filled in — signed, TOTP-confirmed,
refusal-checked and in history like any other task.

Two new actions back the stack buttons: stack_restart and stack_update
(pull every image, then up). Both read the stack's compose files and
working directory from its own containers' labels, never a guessed path,
and point compose at the engine the agent uses. Five built-in tasks are
seeded: Restart / Stop / Start container and Restart / Update stack. The
action reference's Windows Update classification example now uses the
names Windows actually reports.
Each container on a host's page now has Logs. Opening it is a signed task
like every container action — admin only, with a TOTP code — whose new
container_logs step returns the last 200 lines and then keeps polling that
container every second and a half, posting whatever is new. The view polls
the server for those lines; when it is closed, or has not polled for 15
seconds (a hidden tab does not poll), or ten minutes have passed, the
server tells the agent to stop on its next post. Outbound only: the agent
posts, the server never connects to it.

container_logs is low risk and read-only, so it joins the default
allowlist of new installs; it works through monitor mode's read-only
engine socket too. The engine's multiplexed log frames are unpacked, and a
TTY container's raw stream is read as-is. Lines are kept per session, capped
at 2,000, and only for as long as the session lives.
…env, every revision

A stack Vigil manages is now stored in Vigil: its compose file and its
.env, for one host, under a new core app. Every save is a numbered
revision with a note, so a bad edit can go back to one that worked. The
.env is Fernet-encrypted at rest and never comes back unmasked: the API
lists its keys only, edits send each key with a new value or keep: true to
keep the stored one, and showing the values asks for a TOTP code every
time.

A compose file runs as root's engine on the host, so it is checked before
it is stored: it must parse and have services, and may not climb out of
its folder with '..', bind the engine socket or the host's system
directories, run privileged, share the host's PID namespace or add every
capability. New stacks live under /opt/vigil/stacks/<name>. Saving a stack
and revealing its .env emit hooks for the Business audit trail.
… one-time ticket

A managed stack can now be deployed to its host and taken down again, each
as a signed task behind a TOTP code. stack_deploy is high risk — it starts
containers as root's engine — so it also gets the high-risk hold. It
writes the compose file to the stack's folder, then runs compose up -d
with -p <project> against the agent's engine; stack_remove runs compose
down, and with delete_files removes the folder — only ever one Vigil made,
under /opt/vigil/stacks, refused before anything runs otherwise.

The .env never rides in the task: task params are plaintext JSON, and
signing is not encryption. The deploy carries a one-time ticket instead,
which the agent redeems over its own authenticated connection just before
it writes the file, mode 0600. A ticket works once, for its own host only,
for 24 hours, and hands over the revision it was issued for even if the
stack has been edited since. A ticket the server refuses starts nothing.
The task spec runs the same compose checks as the stack store.
Each host's Containers section gains a Managed by Vigil row: one chip per
stack Vigil holds for that host, and New stack. The editor has the stack's
name, its compose.yaml, and its .env as key/value rows whose stored values
are never sent to the browser — a field left blank keeps the stored value,
and Show values asks for a TOTP code first. Saving makes a new revision;
the revision list can load an earlier compose file back into the editor
to save as the next one. Deploy and Take down… ask for a TOTP code and
queue the signed tasks; taking down asks whether to delete the folder too.

Saving a stack, revealing its .env and deploying it are written to the
audit trail (which Business can view) by name and revision — never the
contents.
…ng it

An external stack on a host's page now has Adopt. With a TOTP code it
queues a new stack_read step: the agent finds the stack's compose file and
working directory from its containers' labels, reads the file and the
.env, and hands them to a one-time adopt ticket over its own connection —
never in the task result, because the .env holds secrets. The stack then
becomes managed where it stands, ownership adopted, and appears under
Managed by Vigil with its first revision.

The read also runs compose config --hash against the config hash on each
running container, so the editor says up front whether deploying the
file unchanged would recreate anything, and which services. Adopted stacks
deploy back to their own folder and file name. A stack with more than one
compose file is refused (merge the override first), and so is one whose
compose file Vigil would not run; the ticket records why.
…s last update

Every update that changes a container's image now records the one it
replaced — its repo digest when the snapshot knew it, which survives an
image prune, else its id. The container's row on the host page then offers
Roll back, which opens a new built-in task, Roll back container, with that
image filled in.

The new container_rollback step pins a compose container with an override
file next to its compose file — the compose file itself is never touched —
and brings that one service up from both; a standalone container is
recreated on the image. The container is marked rolled back until its next
update, which deletes the pin (stack updates skip and delete it too) and
clears the mark.
… keeps encrypted

Admins can now store a private registry login — ghcr.io, a local
registry:5000, Docker Hub — at /api/v1/stacks/registries/. The password is
encrypted at rest and write-only: no response ever carries it back. Each
login covers the hosts carrying its tags, or every managed host when it
names none.

When the agent pulls an image through the engine API — pull_image,
update_container, a recreate — it works out the image's registry the way
the engine does, asks the server for that registry's login, and sends it
as X-Registry-Auth; with none it pulls anonymously. The server answers only
for credentials that cover the asking host, so one compromised host
cannot read every registry password in the fleet. Compose pulls still use
the host's own docker login.
The wiki still told admins to put the agent user in the docker group, which
is root-equivalent and is exactly what the read-only engine proxy replaces.
The docker section now covers how the agent finds an engine, the lifecycle
and log actions, managed stacks with their masked env, registry credentials,
and what works under Podman rootful versus rootless.

The AI suggester learns the container actions so it proposes stack_update or
container_rollback instead of shell commands, and EDITIONS lists what is Free
and what the Business audit viewer adds.

The Windows installer now allowlists container_logs like the Linux one does;
the two default lists had drifted apart.
… alerts

Containers were only visible in the host detail. Inventory now has
Containers (running/total, how many outdated) and Stacks columns, computed
in three fleet-wide queries rather than per host. The dashboard container
widget groups stack members together and tints outdated ones peach.

An outdated-image alert now carries an Update container button that opens
the built-in signed task. The hand-built pull_image + recreate_container
YAML it used to offer without AI is gone; it bypassed the built-in task and
its rollback history.
No dialog moved focus in, trapped Tab, made the page behind inert or gave
focus back when it closed, and closed dialogs were only transparent, so Tab
walked through the fields of every dialog on the page. Esc closed the first
open dialog in DOM order, and the deploy dialog and host detail closed
themselves on any Esc, so Esc on a confirm over either closed both.

vigil-modal.js watches the open class on every .modal and .detail-panel
instead of asking twenty-two dialogs to call it, so new dialogs get the
behaviour for free. It keeps a stack: the top dialog takes focus and traps
Tab, everything outside it goes inert, a dialog under another recedes and its
scrim only dims, and Esc, captured ahead of the modules' own listeners,
closes the top one through the module's own close path.

The motion follows the design language's bench: --m-dur, --m-ease and
--m-blur tokens, a quicker exit than entry, and closed dialogs hidden.
Variants per use: Rise for forms and editors, Pop for confirm and prompt,
Pop that becomes a bottom Sheet on phones for pickers, and a shake when the
rebuild hostname does not match.
Vigil had Dark and a white light mode ported from Turnstil. The design
language defines four: Dark, Paper (a warm #ede8bb ground, stored as "light"
so existing light users land on it), River (navy) and Ink (warm near-black,
marked experimental). Their tokens are copied exactly, including the chrome
tokens Vigil hard-coded for a dark ground: the sidebar glass, hover washes,
grid lines, the scrim and the dark text on filled buttons.

Pastels used as text were unreadable on Paper. Every text and stroke use now
reads the -ink partner, which aliases the pastel on the dark themes and is a
deeper ink on Paper, so Dark, River and Ink look exactly as before. The public
status page keeps its own palette since it does not load vigil.css.

Charts drew Dark's greys from literals. They now read tokens through a
scriptable option and redraw on a theme change.

Settings shows the four themes as preview cards, a System option (Dark or
Paper with the OS) and a Reduce motion switch; the sidebar icon steps through
the four. All of it is remembered per browser, as the theme already was.
Filled accent buttons drew their text in var(--bg), which on Paper is cream
on a pastel; they now use --on-accent like the design language, and the
Paper-only override that patched five of the six goes away. Forty-nine
pastel tints were rgba literals, so Paper's stronger tint alpha never reached
them; they go through the --rgb-* channels and --tint-a.

Tabs scroll sideways instead of wrapping on a phone, buttons animate their
colour changes, the focus ring uses the ink accent, and a disabled button
fades to the design language's 0.45.

Two dashboard charts handed Chart.js var(--sky) and var(--mint) as line
colours, which a canvas cannot resolve, so the lines fell back to the
default colour. They read the token instead.
The wiki is a standalone page with its own copy of the Dark tokens, so it
stayed dark whatever the app was set to. It now carries Paper, River and Ink
too, uses the -ink tokens for accent text, and reads the same vigil-theme key
as the app, so it opens in the theme the reader chose in Settings.

docs/UI-STANDARDS.md is for contributors: the principles, which token to
reach for (fill versus -ink versus channel tint), why charts take tok(), how
vigil-modal.js handles every dialog and which variant fits which use, the
component classes, and how to check a change in all four themes.
The Anvil ingest endpoint has existed since M10 but only over the API, so
importing a record meant a curl invocation. The page now has a button that
opens a file picker and posts the record as multipart, reporting the import
counts and reloading the table. Handler wiring is declarative (data-act /
data-change) because CSP forbids inline handlers.
…r stays inside its corners

Dragging the playbook flow canvas to pan let the browser start a text
selection, so node labels (and text in the surrounding modal) highlighted
under the pointer. Suppress selection on the viewport and while a pan is
in flight.

On Vulnerabilities -> Remediation by fix, hovering a fix group painted a
square --s1 background across the whole row, which showed past the rounded
corners of the expanded .vfix-detail box sitting inside it. Tint only the
summary line, with rounded corners, and give the detail the same inset.
…ains how a policy works

The editor modal was 560px wide with parenthetical hints baked into the
field labels, so labels wrapped to different heights and the inputs in a
row sat at three different baselines. Hints now sit under their input as
.pol-hint and the modal gets its own width, so labels stay one line.
App-rule columns were sized by fractions, which truncated "host's own"
and "Present"; fixed columns give the selects room.

Saving a policy left the modal open and jumped to Preview, which read as
a broken Save button. The success path now closes the editor; Preview is
still there when the policy is reopened.

Nothing on the page said what a policy actually does, so a first-time
user had to guess the tags -> rules -> preview -> window flow. A
"How policies work" disclosure states it, and opens itself when there
are no policies yet.
Re-enrolment replaces the host record, machine fingerprints, installer
fixes, seat limits and licence ids. Resolutions: both disk filters fold into
one (snap/erofs plus CD-ROM images), compliance_reports and jackil both stay
Business features, a merge migration joins hosts 0018_host_machine_id with
the M11 chain, and the two new agent test modules install the safety guard.
Containers, compose stacks, Vigil-managed stacks and the live log viewer
move off the Monitor page onto a Containers page in the sidebar, one host
at a time with a host picker. On entry it follows the host Monitor is
showing, else the first pinned host. Monitor keeps a Containers button
that opens the page on its host. The page re-reads every 30 s while open.
Postgres has no max(boolean), so the By update list errored there while
SQLite let it through.
@ConnorHaggerty
ConnorHaggerty merged commit 54fdad8 into main Oct 5, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant