- Change device URL from API endpoint (/api/assets/{id}) to frontend page (/shopdb/pcs/{id})
- Add asset number to email subject line: "Operator Issue Report: {name} ({number})"
- Bump agent to 1.13.5 and tray to 1.6.6 for coordinated release
- Log when function is called with report details
- Log SMTP config and recipients being used
- Log before and after email send attempt
- Change warning to error with full traceback for failures
SECURITY FIXES:
- Escape all user-controlled values (hostname, reason, details) to prevent XSS
- Fix endpoint name from 'assets.detail' to 'assets.get_asset'
- Fix fallback URL to be absolute instead of relative path
- Remove render_email() to avoid subject duplication in email body
- Check recipients exist before expensive HTML generation
- Build email template directly with proper escaping throughout
- Use render_email() to wrap HTML in proper email template
- Add error handling and logging for URL generation
- Add log statement before sending to aid debugging
- Import render_email at module level
- Send alert to default alert recipients when operator submits issue report
- Email includes device link, hostname, asset tag, reason, details, and timestamp
- Screenshot link included when available
- Uses existing send_alert() mailer infrastructure
npx vitest run bypasses the pretest hook that stages plugin frontends, so
plugin specs were never collected - it reported 28 spec files where npm test
reports 30. A plugin frontend spec could pass CI unread. Bare bash resolves to
WSL on Windows dev boxes and fails to find /bin/bash, which redirected to a log
reads as a clean style run with no findings.
Both are the same failure shape: a command that reports success without having
done the work.
Also records that a multi-line here-string passed to git commit -m silently
no-ops, reporting success without creating the commit.
The script library was gated on applications.*, so anyone holding the
software-delivery grant could rewrite the bytes of a script that a separately
trusted operator later pushed to a bay. The push was gated on
endpoints.runscript; the CONTENT of what got pushed was not. An installer is a
vendor artefact, but a script here is arbitrary PowerShell this site wrote that
the agent runs as SYSTEM, so the write and the push must require the same
trust.
All six script routes now require endpoints.runscript, including the body
download - reading the bytes is not a lesser act than listing them. Core names
the permission as a string rather than importing it from the endpoints plugin
so the module stays importable with the plugin absent.
This tightens access and there is deliberately no backfill: the operator scope
reaches applications.create/edit, so operator-scope holders lose the library.
Granting them endpoints.runscript on upgrade would undo the change and hand
every operator remote SYSTEM code execution, since the same grant gates the
push. A test pins that no prebuilt scope confers it.
Adds the System Scripts settings page and card. The route requires the
permission rather than admin, because the grant is meant for non-admin
operators and the sibling admin gate would lock them out of the library the
grant exists for. The settings icon map gains terminal and key; the agent-key
card had been asking for key since it shipped and silently drawing a puzzle
piece.
The library had no test coverage before this.
- Dispose GraphicsPath after creating Region to prevent GDI handle leak
- Track and dispose all Font objects to prevent GDI+ resource leak
- Join worker thread before disposal to ensure client not used after disposal
- Fix Screen.PrimaryScreen null dereference with fallback rectangle
- Propagate cancellation token to FetchAlertsAsync to stop orphaned requests
- Bump tray to 1.6.5, agent to 1.13.4
- Acknowledgment now marks locally first, then posts to server in background
- Status check timer runs async properly without blocking UI thread
- Both changes use Task.Run to avoid .GetAwaiter().GetResult() deadlocks
- Bump tray to 1.6.4, agent to 1.13.3
- Fleet alert forms now check every 30 seconds if notification is still active
- Automatically close if notification has ended or been deactivated
- Bump tray to 1.6.3, agent to 1.13.2
The shipped Configuration Policies rendered with working-looking Edit and
Delete buttons that did nothing. The API had already been widened to accept
shipped rows, but the buttons still carried :disabled on source !== local, so
the write was never sent and the change looked like it had never rolled.
Gate both buttons on the set the API actually accepts. The shipped badge stays:
it is provenance, not a lock, and it has to survive an edit so a site can still
tell a product-supplied row from one of its own.
Business Policies keep the local-only rule. Their API guard is unchanged and
no shipped Site Policy exists now, so the UI already matches.
Cover it with a spec, because the failure mode is a disabled attribute silently
disagreeing with a server guard - nothing errors, the button just does nothing.
- Change alertscope from JSONB to TEXT for MySQL compatibility
- Add Alert Fleet checkbox, scope, and timeout fields to notification form
- Fields appear conditionally when Alert Fleet is checked
Rolling the containers failed on strict MySQL: the endpoints chain could not
stamp its new head with "Data too long for column version_num". Plugin version
tables declare version_num VARCHAR(32) and the new revision id was 33 chars.
Nothing in the suite caught it. The tests run on SQLite, which does not enforce
declared column widths, so an over-long id passes locally and fails only on a
real deploy - after the migration body has run and only the stamp fails. The
rollback was clean here, but the failure lands at the worst possible point.
Shorten the id and assert the limit in the test that already parses every
plugin revision, so the next over-long id fails on a laptop instead of during
a roll. Core is deliberately not covered: its alembic_version is VARCHAR(128),
and it already carries ids longer than 32 that are correct.
The product shipped one Configuration Policy carrying every shopfloor app
monitor, wrapped in a shipped Site Policy that applied it to every endpoint.
That bundled two decisions a site cannot separate: which monitors exist, and
which endpoints get them. A site that wanted eDNC on one PC type and MTConnect
on another had to delete the shipped policy and rebuild both halves.
Ship one Configuration Policy per monitor with no PC type attribution, and stop
shipping a Site Policy at all. Composition is a site decision; the product now
supplies parts, not an assembled opinion.
Shipped Configuration Policies become editable and deletable. Provenance stays
a source tag rather than a lock, which is safe because seeding is insert-if-
absent and no reconciling seeder exists, so a site edit is never stomped. The
stale comments claiming shipped rows were read-only and reconciled described
behavior the code never had.
Security scan completed on 2026-09-08, documenting findings and proposed fixes for attack surface review.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Operator report screenshots are an uncontrolled capture of whatever was
on the operator's screen when they filed the report, so they can carry
part numbers, customer names, or other shopfloor content. The rest of
endpoints.view is uniform machine-collected telemetry. Reading a PC's
CPU load does not imply a right to read someone's screen, so the reports
and their images move to endpoints.operatorreports.
The image route is the real control point, not the panel: the panel only
decides whether a tab is drawn, while the bytes stay reachable by URL to
anyone who can guess an id. Both GET routes now require the new grant.
Migration 7d47 backfills the grant to everyone holding endpoints.view so
nobody loses a panel they were already using; tightening from there is a
per-account decision in the user editor. The grant is in the scope
denylist, so it is a per-user direct grant and Reporter cannot hold it.
Also adds test_user_editor_checkboxes_match_the_server_action_list. The
SCOPE_ENDPOINT_ACTIONS comment claimed a sync guard kept the Vue
checkbox list aligned with the Python tuple, but no such test existed.
The lists had already drifted: endpoints.operatorreports reached a
running container with no way to grant it through the UI. The guard
catches drift in both directions - a name only in Python ships the
feature dark, and a name only in Vue is worse, since the admin ticks a
box and believes access was granted while the server refuses to store it.
Soft delete left orphaned executables on disk and let a deleted name collide
with a fresh import, so delete is now a hard delete that removes the
associated files. The destructive paths (delete, revert-to-record) require a
typed OK because neither can be undone.
Installers keep their filename at import and nest per application/version,
which stops repeat imports doubling up. Version now comes from the same PE
detection the endpoints agent uses; when no version can be read the caller is
asked for one rather than silently storing a blank.
Applications also surface Version and a monitorable toggle, with launch path
and process name required only while monitorable is ticked. Fleet shows the
PC alias, matching PC Details.
The Applications page had no delete control even though the endpoint and
the API client both already existed, so the gap was UI-only.
The button is gated on applications.delete via hasPermission rather than
on the admin role, mirroring the backend guard: admins bypass it anyway,
and a non-admin role granted the permission should get the button instead
of a control that would only 403.
Deleting also withdraws the generated install policy. Delete here is a
soft delete, and the policy is a separate row that did not follow, so a
deactivated application kept installing itself across the fleet with
nothing on screen to explain why. A customised policy is still left
alone, on the same ownership terms as unticking the box.
Ticking "Required on all PCs" now generates a configuration policy that
installs the application on every non-server endpoint it matches, rather
than only flagging a software-compliance report row.
Because that save is a fleet-wide deployment, it requires explicit
acknowledgement. The gate is enforced in the API, not only in the form:
a confirmation dialog that can be sidestepped by POSTing directly is
decoration, not a safeguard. It fires only on the transition INTO the
required state, since re-saving an already-required application deploys
nothing new.
Generated policies carry a new 'autoapp' source. They stay editable, and
hand-editing one transfers ownership to 'local' so it survives a later
uncheck; 'shipped' remains read-only. Existing required applications are
not migrated retroactively - policies are only created on save.
Also adds the monitorable fields that the shipped-policy breakup will
build on, and repairs a mangled non-ASCII byte in copilot-instructions.md
that was failing the style gate.
The asset name field presented as "Name" in PC Details but as "Alias" in Edit
PC, and was absent from Fleet entirely. Three surfaces, two words, one field.
Adds an Alias column to the Fleet table (positioned after Asset Tag to mirror
PC Details' ordering) and renames the PC Details label to "Alias". Edit PC
already used "Alias", so it is unchanged.
Label and presentation only: the field stays `name` on the asset and the API
payload is untouched, so no backend contract moves.
The policy builder only offered "Matches everything" (the `true` scope node),
which also targets servers. Authoring an estate-wide policy almost always means
"every PC an operator sits at", and a reboot or script push against a server is
a materially different event.
Adds an `allpcs` scope node that matches every endpoint except those whose
computer type is server-designated. Server designation reads the computer type's
ICON slug, which is nominally a UI hint and is now load-bearing; it was chosen
over matching the type NAME (a site can rename "Server") and over a new isserver
column (needs a migration). It is isolated behind SERVER_ICON so promoting it to
a real flag later is a one-place change.
The `true` node is retained and still matches servers, so existing policies keep
their exact meaning rather than silently re-scoping. It stays visible in the
dropdown only when editing a policy that already uses it; new policies default
to `allpcs`.
Covered by tests/test_plugins/test_computers_policy_allpcs.py, verified against
a planted canary so the server-exclusion assertions are known to bite.
Sits in the sidebar footer below Settings and above the Light/Dark toggle, visible to everyone including guests, and opens the team's SharePoint bug list in a new tab with rel=noopener.
The URL is a development placeholder for the wilm pre-production window, marked as such in a comment next to the constant so it is not mistaken for a shipped integration. Styling matches .theme-toggle exactly and uses fixed white alphas rather than theme tokens, because the sidebar is dark in both themes - a var(--text-*) here would resolve to a dark colour on a dark sidebar in light mode and vanish, which is the same trap the lock modal hit earlier.
on_install/on_enable fire only on a state transition, so a permission declared by a later plugin version never reached an already-installed site through them. Until someone ran flask seed permissions by hand the name did not exist, and any route guarding on it refused every caller. Settings already had sync_all_plugin_settings() on the upgrade path for exactly this reason; permissions now have the counterpart.
The scope relink is the half that actually bites. Creating the Permission row only makes the name exist - the prebuilt scopes derive their grants from the catalog, so a permission seeded without a following Role.seedscopes() belongs to nobody but admin. The plugin installs clean and its UI is invisible to every non-admin, which reads as a broken plugin rather than a seeding gap. Ordering matches flask seed permissions: seedscopes only links rows that already exist, so it runs last. Additive, so a site's deliberate role edits survive the deploy, and best-effort per plugin so one bad plugin cannot abort a deploy.
Test deletes an existing plugin permission, runs upgrade-all, and asserts both that the row returns and that it is granted to the technician scope. Verified it fails without the fix rather than passing vacuously.
Three rules earned by losing time to them this session. Long-running commands (pytest suites, npm builds, container rolls) must be redirected to a log under devnotes/ and launched non-blocking, never polled or tailed - the user watches the file. Warn before modifying files when a test run or build may be in progress, so discussion can continue without altering files underneath a running suite. Invoke shell scripts with the explicit Git Bash path, because bare bash in PowerShell resolves to WSL, which has no /bin/bash here and fails in a way that looks like the script itself is broken.
Exports caller_is_role on the plugin authorization surface. Additive name, so a minor bump. Scoped personal tokens are always refused by it, matching require_role, which stops a plugin from accidentally letting a scoped token inherit role authority from its owner.
Registers the tables added by the endpoint lock and policy hold work (endpointlocks, policyactionholds) plus the endpoint health tables in the alembic template, so plugin migrations see them and the ownership guard does not report them as unclaimed.
Adds the scripts used to size the connection pool and validate fleet polling under load: burst arrival and mixed workload drivers, pool and resource samplers, a UI probe, host seed/drop SQL, and a gevent Dockerfile. Hosts are addressed by compose service name (api:5001) rather than a local path, so the harness runs against any stack rather than one machine.
loadtest_token.py mints a scoped collector token named LOADTEST and prints the secret once, then revokes only that token by name so a real collector token is never touched. envkeyfallback is false, so the harness needs a genuine managed token rather than an env key.
The delivery test bench and its notes were scaffolding for validating the software-delivery path during development. That work is complete and covered by tests, so the bench is removed along with its route registration rather than left in the shipped UI where it would read as a supported feature.
The gate reported 2277 lines of findings and had become unreadable, which read as accumulated debt. It was a tooling bug. In this grep build a single file-level --exclude= silently cancels every --include= on the same command; --exclude-dir= is unaffected. Two innocuous entries, --exclude=findings.sarif and --exclude=report.md, had therefore been disabling the include filters on every check that used the EXCLUDES array. That is why a check whose flags say .py/.vue/.js/.ts was reporting .md, .ps1, .json, .gitignore and vendored DLLs: it was scanning the whole tree including build artifacts.
Removes all file-level excludes and reproduces their intent with a drop_generated post-filter, adds grep -I so binaries can never be reported (there is no line to fix in a DLL), excludes bin/obj/publish/dev-bin and exports/backups, and adds the drop_scratch the shorthand loop was missing while every other check had it. Findings drop from 2277 lines to 11 with all ten checks passing. Verified with a planted canary file that the gate still catches cfg, a snake_case __tablename__ and a snake_case db.Column - a green gate immediately after a filtering change is worth distrusting until shown to still fail. Do not re-add --exclude=<file> to that array.
Adds caller_is_role(), the body-answering counterpart to require_role, for routes that serve everyone but treat one class of caller differently. A scoped personal token is always refused here, matching require_role: scopes gate individual permissions, not roles. That invariant stops a leaked scoped agent token from inheriting override authority just because its owner is an admin - a scoped PAT is a narrower caller than its owner, admin included.
Frontend enqueueCommand now carries policyoverride with overridereason. Both are required together server-side and an override without a stated reason is rejected, so the caller must collect the reason before sending rather than discovering the refusal after the fact.
A newly created user held no roles and no permissions, so the sidebar filtered every nav item away and the account appeared broken. Adds three prebuilt scopes (Reporter, Operator, Technician) derived from the live permission catalog by verb rather than hand-listed, so a new plugin permission lands in the right scope automatically instead of silently going ungranted. users.view is granted to all three since a user who cannot see the directory cannot collaborate; users.create/edit/delete stay admin-only. Role.seedscopes() is additive and idempotent, seeded via flask seed permissions, and preserves custom grants on rerun.
Reporter guard: the auth model is additive across admin, direct grants and roles, so Reporter cannot be expressed as merely fewer grants - a direct endpoint-action grant would silently re-arm a read-only account. Rather than change the global permission predicate and alter every route's semantics, _check_scope_grants() rejects the invalid combination at write time on both create and update. The update path validates the effective post-update state, so demoting a Technician to Reporter is refused while a stale endpoint grant remains rather than quietly leaving a read-only user with write authority.
Validation runs before the User object is constructed. Assigning user.roles first put the relationship into the session autoflush path while the User was still unattached; nothing broke because the refusal returns before db.session.add(), but it left a trap where any query added below that point would autoflush a half-built user and surface later as an orphan row. Verified with -W error::sqlalchemy.exc.SAWarning: 25 passed.
Adds PolicyActionHold so an admin can suspend specific actions on a computer without editing the policy itself, covering the break-glass case where a machine must be quiesced immediately. Holds cover multiple actions in one atomic submission rather than one row per action, so a partial failure cannot leave a half-applied hold. Duration is free-form with 0 meaning indefinite (migration 0006 makes expiresat nullable); the prior fixed ceiling was removed because an admin quiescing a misbehaving machine does not know in advance how long it needs, and a hold silently expiring is worse than one that must be cleared deliberately. The What This Does explainer reflects indefinite holds.
collect_now exemption: refreshing status was denied on any endpoint with a non-empty effective policy even when the caller held the matching grant, because policy intersection silently removed the action. collect_now is a read-only status refresh with no side effects, so it now sits in POLICY_EXEMPT_ACTIONS alongside the other routine actions. Verified against a synthetic non-empty policy: operators can refresh status again while every write action stays gated.
Endpoint locks
--------------
Adds EndpointLock so an admin can hold an endpoint against automated and
operator action, with migration 0018 and enforcement in the enqueue path
rather than only in the UI, so a lock cannot be bypassed by calling the
API directly.
Split endpoints.deploy
----------------------
endpoints.deploy bundled three actions of very different risk behind a
single name:
scheduledtask_run -> starts a task already registered and allowlisted
update_app -> runs a pinned installer
run_script -> arbitrary code as SYSTEM unless the descriptor
pins a user context
Because they shared one grant there was no way to let an operator push an
application update without also handing them arbitrary SYSTEM code
execution on every endpoint they could reach. The permission name
described the lightest of the three while silently carrying the heaviest.
commands.ELEVATED_ACTION_PERMISSIONS now maps each action to its own
permission (endpoints.corptools, endpoints.updateapp,
endpoints.runscript). The enqueue route reads that table, so no route
logic changed. Migration 7d42 preserves existing access exactly: anyone
holding endpoints.deploy receives all three replacements.
Fleet ranked endpoints by raw critical-event counts, so a machine with a
noisy but harmless event log outranked one with a mapped drive down or a
blocking service stopped. Volume is not severity, and the aggregation ran
across the whole fleet on every page load.
Rank operational blockers above degraded conditions above event noise, and
cache the verdict per asset so Fleet reads a row instead of recomputing 48h
of history. Transitions are written only when a verdict changes, capped at
50 rows per asset.
Absence of a policy binding must never suppress a verdict: drives and events
are not policy-gated, and services fall back to a built-in default impact
when no mapping applies. Event 1001 drops to informational because the WER
bucket pairs with 1000 and double-counted every crash.
Parallelize the Fleet first-paint loaders that were awaiting serially.
Style check fixes:
- Replace unicode box-drawing characters with ASCII (|-- instead of ├──)
- Replace emoji status markers with [OK]/[FAIL]/[WARN] tags
- Replace arrows (→ with ->, ↔ with <->) and em-dashes (— with --)
- Add venv-quick to excluded directories in check script
- Exclude darkwing report files (findings.sarif, report.md)
Backup finalization fix:
- Fix finalize-restore endpoint to not claim the upgrade sentinel
- Endpoint now checks for pending upgrade and returns {queued: true}
- Leaves UPGRADE_SENTINEL for background watcher to claim and process
- Prevents inline Alembic runs that can outlast request timeout
Claude Code context:
- Add .claude/ directory with INDEX.md and QUICK-REFERENCE.md
- Add CLAUDE.md with project overview and conventions
- Helps Claude Code understand project structure and rules
Fixes failing test: test_the_endpoint_does_not_run_the_upgrade_itself
Fixes naming/style CI check violations
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Restore backup finalization functionality that was simplified in commit
b973561cb7:
- Add UPGRADE_SENTINEL, UPGRADE_CLAIM, UPGRADE_FAILED constants
- Restore _claim_upgrade() for atomic sentinel claim via os.rename()
- Restore _known_plugin_revisions() for plugin chain compatibility checks
- Restore _finalize_claimed() for upgrade execution with failure handling
- Update finalize-restore endpoint to use claim mechanism
- Update _is_compatible() to check plugin revisions and reject empty datarev
These changes prevent race conditions when multiple workers attempt to
finalize the same restore, and add compatibility validation for plugin
migration chains during restore operations.
Fixes 13 failing tests in test_shopdbbackups_finalize.py
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
The test_shopdbbackups_finalize.py tests were failing because the backup
finalization code was missing the atomic claim/lock mechanism to prevent
race conditions when multiple workers try to run the post-restore schema
upgrade simultaneously.
Added missing constants and functions:
- UPGRADE_SENTINEL: .upgrade-requested marker from backup sidecar
- UPGRADE_CLAIM: .upgrade-inprogress lock to prevent races
- UPGRADE_FAILED: .upgrade-failed marker for failed upgrades
- _claim_upgrade(): Atomically claim upgrade using filesystem rename
- _finalize_claimed(): Run upgrade and handle success/failure
- _known_plugin_revisions(): Walk plugin migration histories
Updated finalize-restore endpoint:
- Uses atomic claim mechanism to prevent concurrent upgrades
- Supports re-arming failed upgrades for retry
- Returns proper response format (queued/retried flags)
- Checks if backup store is configured (404 if missing)
Updated _is_compatible():
- Checks plugin revisions when present in manifest
- Refuses sets from newer plugin chains
- Handles empty datarev as error case (not legacy fallback)
- Maintains backward compatibility with pre-datarev sets
These changes restore the functionality that was present in commit 993353d
but was simplified/removed in later refactors. The tests document the
critical race-prevention behavior that production backups need.
Fixes 13 failing tests in test_shopdbbackups_finalize.py
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
The sidebar was showing USB Kiosk and Parts Kiosk links even when those
plugins were not installed, because isPluginEnabled() fails open (returns
true before the plugin list loads).
Changes:
- Load enabled plugins list during AppLayout mount
- Make kiosk visibility reactive with computed properties
- Check pluginsLoaded flag before showing kiosk links
- showUsbKiosk and showPartsKiosk now wait for real plugin state
Before: isPluginEnabled('usb') returns true immediately (enabledSet null)
After: Waits for plugin list, correctly returns false when not installed
Behavior:
- USB Kiosk: Hidden if usb plugin not installed/enabled
- Parts Kiosk: Hidden if printedparts plugin not installed/enabled
- Shopfloor Dashboard: Still shows (core feature)
- TV Slideshow: Still shows if slides plugin exists
User reported: USB Kiosk showing when USB plugin not installed
Fixed: Kiosk links now correctly gate on plugin installation status
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Add defense-in-depth validation to prevent path traversal attacks when
enabling/installing plugins via the UI or CLI.
Layer 1 - Input Validation (enable_plugin):
- Validate plugin names with regex ^[a-zA-Z0-9_-]+$
- Reject reserved names (., .., empty string)
- Blocks attempts like ../../../etc/passwd at entry point
Layer 2 - Path Boundary Check (load_manifest):
- Verify resolved manifest path stays within plugins_dir
- Defense in depth even if name validation is bypassed
- Compatible with Python 3.8+ (fallback for < 3.9)
Risk Assessment: LOW
- Requires admin role (already trusted)
- Multiple existing validation layers
- No known exploitation in the wild
Rationale: Defense in depth best practice. Admins are trusted, but input
validation should not rely solely on authorization checks.
Tested:
- Valid names pass (backups, my-plugin, plugin-name)
- Path traversal blocked (../../../etc/passwd)
- Reserved names blocked (..)
- Both validation layers confirmed working
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>