101 Commits

Author SHA1 Message Date
cproudlock
316e79db53 Correct the CI claims and the agent tooling's honesty about itself
I asserted that the MySQL migration job "was a comment, not a job" and that
nothing had ever run a fresh install on MySQL. Both wrong. The job existed, hit
this org's ARC runners, and was deliberately moved to internal CI in ffe9fa1cc
because Kubernetes runners without docker-in-docker cannot host service
containers - a fact written at the bottom of the very file I was editing. The
job I added would have failed at container setup on its first push.

Removed it. The leftover was the HEADER, which listed a job that had moved, and
that is now corrected in place with a note not to re-add the entry without
re-adding a job to a runner that can host it. Coverage stays on internal CI.

The api-contract job was green by accident. gen_openapi.py refuses to run while
a bundled plugin is disabled, instance/plugins.json is gitignored, and the step
only worked because the earlier pytest step writes that file at import time and
leaves it. Reordering the steps would have produced a failure about disabled
plugins that had nothing to do with the spec. It now seeds its own registry.
Confirmed against a clean archive of HEAD, where the generator does refuse.

shopdb_whoami claimed to report "what the token is scoped to" and reported the
ACCOUNT's permissions. A read-scoped token on an admin account printed
roles=[admin] and the full permission count, so a model would try a write, take
a 403, and have no tool that explained it. The field is now account_permissions
and carries a note saying a scoped token may allow fewer. Fixing this properly
means /api/auth/me publishing the patscopes claim, which is a separate change.

The MCP client caught only urllib's two error types, so an ordinary mistake
escaped as a raw exception with no cause reaching the caller: a space in a path
raises InvalidURL, a non-ASCII path raises UnicodeEncodeError, a slow read
raises TimeoutError. Paths are now percent-encoded and those escapes are
contained; both cases return the API's own 404 envelope instead.

The operation index collapsed optional authentication. Fourteen routes work
signed out and return more when authenticated, and the index printed them as
requiring a bearer token. They now read bearerAuth,optional.

Also: the generator's own hint told the reader to run "flask plugins enable";
the CLI group is "plugin".

(cherry picked from commit a97346ed81)
2026-09-20 14:50:02 -04:00
cproudlock
f9d6b57f0f Move the documentation to the line that ships, and correct it against the code
The docs lived on one lineage and the fleet code on another. That is why six
pages described a shared fleet key as how an agent authenticates, why
COLLECTOR-AUTH had to warn that the per-PC credential code was not in the
repository it sat in, and why ADR-019 recorded a decision whose implementation
was somewhere else. Documentation that cannot see the code it documents goes
stale by construction, and no amount of editing fixes that.

So the corpus moves here: 84 files, 56 pages, 20 ADRs, the proposals, INDEX.md,
llms.txt and both gates. Filenames are unchanged, because a wiki page name is
derived from the filename and a rename breaks every link anybody saved.

THE POINT OF THE MOVE IS THE CORRECTION IT ENABLES. Every caveat that said "not
in this tree" was false the moment the files landed beside the code, so each was
rewritten from what is actually here:

- COLLECTOR-AUTH gained the real enroll request body, the two bounds on the
  unauthenticated route (per-IP rate limit, pending cap with stale rows dropped
  before a real PC is refused), and the rejected pending reason that binds to
  the endpoint identity rather than the nonce.
- ADR-019 now describes an implementation that is present, including its tests,
  and says plainly that what remains is the FLEET migration, not the code.
- proposals/device-identity claimed phase 1 was unbuilt. plugins/computers
  declares deviceid on the collector schema and prefers it over the older
  fallbacks. Corrected in the page, its summary and the index.

WINDOWS/IIS IS NOW DOCUMENTED AS THE LEGACY PATH, per the product decision, and
carefully: it is what production runs today. Every Windows page opens by saying
it is supported, not for a new site, and where a new site should go instead. No
procedure was cut - an operator on an IIS box at 2am needs these pages to work
exactly as well tomorrow. MIGRATE-TOPOLOGY stops being a footnote and becomes
the off-ramp, which is why it nearly doubled: what was there did not match how
the Docker stack really works here.

The Docker material that already lived on this line was kept and given
ownership rather than buried under the ported Windows-centric prose: DEPLOY now
describes the four services, the migrate service that runs the upgrade chain
before api starts, TLS terminated by nginx, and the plugin count corrected from
thirteen to seventeen - a number both lineages had wrong.

ROADMAP is rewritten from evidence rather than memory: what has shipped, read
out of the changelog, the ADRs and the manifests, and ahead of it exactly two
items - finalize a distributed release and polish the setup wizard.

PROJECT-MAP is generated and was stale; the GENERATOR was what was wrong.
--check passes now.

API-REFERENCE is retired to a pointer. /api/docs serves 573 operations with
derived auth, query parameters and harvested examples, and a hand-written page
competing with that is a liability. The file stays so its wiki page and its
inbound links survive.

AND THE MOVE TURNED ON A SCRUB THAT HAD NEVER RUN HERE. test_docs_publishable
keys on docs/*.md, which this line did not have, so it skipped - its own comment
predicted what would surface. Porting the corpus activated it and it found 48
lines that must not reach a published wiki: branch names abbreviating the site,
assistant-tooling paths, the site name in two deployment pages, and an employee
ID in a Windows path that the scrub does not even look for.
2026-09-19 09:10:15 -04:00
cproudlock
5aa748cab5 Publish examples and query parameters, and stop the harvest leaking secrets
The spec described 573 operations with a schema each and almost nothing a
reader could copy: 12 operations carried an example, and no operation described
a query parameter at all, because a Flask handler's query string is invisible to
url_map. A route whose real interface is ?code= documented nothing.

Both are now derived from evidence rather than written by hand.

QUERY PARAMETERS come from each view's AST - request.args.get, the subscript
form, getlist, and this repo's own helpers - and a read that does not resolve to
a literal name is not emitted. 0 to 87 operations, 350 parameter objects. The
enumerations some handlers validate (sort=assetnumber|hostname) are deliberately
NOT published: deriving the accepted set reliably needs more than a literal
read, and a wrong enum is exactly the invented contract this work avoids.

EXAMPLES are harvested from the test suite, which already sends real bodies and
asserts real responses, so they are transcripts rather than inventions. 12 to
about 324. The harvest is re-runnable and optional; generation works without it.

THE HARVEST PUBLISHED TWO REAL CREDENTIALS, and that is the important part of
this commit. A bootstrap token and a kiosk setup code reached docs/openapi.json
inside URL values - a file served at /api/docs on production and copied to a
wiki. Neither scrubber layer could see them: the keys are 'scripturl' and 'url',
which name no secret, and the opaque-value rule excludes ':' and '/' on purpose
so that example URLs survive. Nothing was committed.

Four changes came out of it:

- a URL-credential rule that scrubs the parameter VALUE and keeps the URL, so
  ?t= is redacted and ?page=2&perpage=50 still reads as an example
- redaction runs on the way OUT as well as on the way in. Harvested examples
  live in the inventory and a refresh preserves them, so a scrubber fixed after
  a harvest protects only future harvests while the stored copy keeps
  publishing. The published file can no longer carry a secret the current rules
  would catch, whatever the inventory holds
- the leak gate now holds its own patterns instead of importing the generator's.
  Checked with the scrubber's own rules it could only ever assert that redact()
  ran, and it was green while both tokens sat in the file. It now knows the
  product's token prefixes, a URL-credential shape, and a crude entropy
  backstop. Replanting the exact credential that shipped fails it
- a DELETE only declares a body when the HANDLER reads one, derived from its
  AST. A transcript proves the client sent something: the suite posts {} to
  routes that ignore it, and that published "this route reads a JSON body" on
  DELETE /api/endpoints/agentkey, whose handler never calls get_json.

249 operations still carry no example, because no test exercises them with a
JSON body. The honest fix is a test, not a hand-written example, and the next
harvest picks each one up for free.
2026-09-18 20:18:06 -04:00
cproudlock
8fc6df8f0a Describe the API this branch actually serves
docs/openapi.json is served live at /api/docs on the production box and
documented none of the routes added since 2026-09-11. It is generated from
docs/api-inventory.json, which is not tracked on this branch, so the generator
had no input - and tests/test_api_inventory_parity.py skipped itself for the
same reason. No route here had ever been checked against the published contract.

The inventory is now generated from this branch's own url_map rather than
carried over from elsewhere, with each route's auth derived from the decorators
and gates on its view rather than guessed. A route whose gate cannot be
classified is marked for review, not quietly called public. gen_openapi.py can
refresh it from the live app, so this does not rot again, and human-authored
prose survives a refresh.

The parity test now RUNS instead of skipping.

The thing that mattered more than the missing pages: nine operations were
published with "security: []" - the machine-readable claim that no credentials
are needed - while their handlers require one. Two of them serve the bootstrap
installer and the agent package. A generated client, a firewall plan or anything
else reading the spec would have been told those are anonymous. Required auth is
now expressed as required, and a token-or-network gate is expressed as optional
auth with the network half named in the description, which is what it actually
is.

A test re-derives auth from the live app and fails when the committed inventory
disagrees, because the parity check only ever compared route lists: a wrong auth
string passed everything.

test_openapi_spec.py asserted that optional auth was the majority of the
surface, a premise the guest-role and permission model has made false. It now
holds the property that matters - an optional-auth route is expressed as
optional - rather than a count that was true once.

gen_openapi.py also loads .env before building the app, like every other script
that builds one, or a plain python run falls back to the hardcoded localhost URL.
2026-09-18 13:31:11 -04:00
cproudlock
85f69038d8 Check the things the gates were reporting green without checking
Four gates that were not gating.

NO MIGRATION COLUMN NAME HAD EVER BEEN CHECKED. The naming gate matched
db.Column attribute assignments only, so every column a migration creates has
bypassed the lowercase-concatenated rule since the gate was written. It now
reads migrations too, in the shapes they are actually written: sa.Column in
create_table and add_column, alter_column's new_column_name, the batch
equivalents, the keyword form, and a literal with a leading capital or a string
prefix. Run against the tree it finds nothing, which is now a fact rather than
an artefact of the pattern.

THE ADR-015 SITE-LITERAL SCAN DID NOT REACH THE C# AGENT, twice over: agent/ was
not in the directory list, and a file-level --exclude alongside the --include
list changes which files grep matches at all, so the .cs include was not doing
what it looked like it was doing. A hostname or base URL compiled into the agent
would have shipped to another site with no CI signal.

NO PLUGIN MIGRATION WAS CHECKED FOR TOUCHING SOMEBODY ELSE'S TABLES. ADR-008
says plugin chains own plugin schema and the core chain is reserved for core
tables; the endpoints chain broke that, added and then dropped columns on the
core assets table, and nothing would have stopped the next one. The scan walks
every plugin chain, resolves each DDL op to its table, and fails when that table
belongs to core or to another plugin - including through op.execute and through a
bind, which is this repo's dominant idiom and the shape a check keyed on the
receiver name would have missed entirely. Statements are classified one at a
time, so a leading DML verb cannot hide trailing DDL. The two historical
crossings are allowlisted by plugin, file, op, table AND column, so the shipped
history stays green and anything new goes red.

AND THE GATE RUNNER ITSELF PASSED WITHOUT RUNNING ANYTHING. In a checkout with
no venv, `python` is often not on PATH; the pytest line ends in `|| true`, so the
run died 127, the report was one command-not-found line, every gate matched
nothing, and it still printed "All gates ran." and exited 0. That exit code is
what everything else trusts. It now refuses to run without an interpreter that
can import pytest.

Recorded rather than fixed, with the reasoning in both files: the publishability
scrub keys on docs/*.md, so prose that lives only in docs/wiki-drafts/ does not
turn it on. Whether those drafts are internal or headed for publication is a
question about what they are for, not a bug to close by rewriting them.
2026-09-18 13:30:58 -04:00
cproudlock
7480e4fe3a Give each endpoint its own credential and fix the review findings on the server
Per-PC credentials (replaces the 09-14 client-certificate attempt):
- POST /api/endpoints/enroll issues a credential to a PC that matches exactly
  one computer by hostname and BIOS serial and has none yet. Anything else
  (unknown or duplicate hostname, serial mismatch, already enrolled) waits as
  a request an admin approves or rejects. The agent's persisted nonce binds an
  approved request to the PC that asked.
- One auth helper serves every agent route. With X-Endpoint-Credential the
  asset comes from the credential and a request naming another PC gets 403
  ASSET_MISMATCH. The shared key still works for PCs that have not enrolled,
  is refused (401 SHARED_KEY_REFUSED) for any PC that has, and can be switched
  off. A revoked credential gets 401 CREDENTIAL_INVALID so the agent
  re-enrolls.
- The core collector accepts the credential for the computers, printers and
  endpoints collectors through two new plugin hooks exposed on shopdb.api
  (contract 0.33.0), and refuses the shared key for enrolled PCs.
- Admin API: enrollment list, approve, reject, revoke (un-enroll), settings.
  Migration API: per-PC readiness with blockers, and upgrade waves that
  package the agent once and queue the existing agent upgrade per PC.
- Migration 0023 creates endpointcredentials, endpointenrollrequests and
  endpointmigrationwaves, drops the certificate columns from assets where
  test databases have them, and renames app_fingerprint to appfingerprint.
  0021 and 0022 keep their ids but no longer alter the core assets table.
- Removed: client_cert.py, certificate renewal, the unauthenticated
  /test/client-cert route, nginx client certificate forwarding and the agent
  certificate script. The client-cert header could be forged on IIS, the key
  was readable by users, and the shared key remained a fallback.
- GET /settings/server-cert returns the SPKI SHA-256 pin for GE-Enforce.

Review fixes:
- report_command_result used g without importing it; every result was a 500.
- collect_log refuses setup answer files, appsettings, agent directories and
  key files at enqueue.
- Fleet alerts: alerttimeout and alertfleet agree with the migration, the
  scope is validated and a malformed scope no longer reaches the whole fleet,
  and the ack race no longer raises NameError.
- A refused break-glass override leaves no hold and no audit row.
- Agent upgrade checks locks and permissions before repackaging, and writes
  the package atomically.
- Server certificate upload validates the pair before replacing files;
  generation no longer builds an openssl config from user input; audit rows
  are actually written.
- Health evaluation runs in a savepoint so a caught error cannot lose the
  check-in.
- Required-app policies are found by appid, survive renames, and deleting an
  app deactivates policies that still name it. The UI and API now say such a
  policy only takes effect inside a business policy.
- Backfill handles duplicate fingerprints and rolls back a failed batch.
- Plugin settings routes skip a plugin whose hook raises.
- The setup token is no longer printed to the log.
- The naming gate checks top-level files again and handles odd file names.
- Removed root scratch scripts, one of which carried a token.
- Rewrote the fleet alert scheduling and app delta tests on real fixtures.
2026-09-15 16:07:06 -04:00
Proudlock
5cd5332397 Add agent fingerprinting, client certificate auth, and security audit documentation
Some checks failed
CI / backend (push) Has been cancelled
CI / naming (push) Has been cancelled
CI / gates (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / lean-build (push) Has been cancelled
This commit adds:
- Database migrations for app_delta fingerprints and client certificates
- Scripts for certificate generation and fingerprint backfilling
- Comprehensive security audit reports and findings tracking
- Implementation documentation for certificate authentication
- Test utilities for certificate renewal and fingerprinting
- GE-ENFORCE collector fix documentation

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-09-15 09:18:12 -04:00
Proudlock
4943e182c3 Optimize check-naming-and-style.sh for 5x faster execution
- Use git ls-files instead of grep -r for ~5x speedup
- Only scans tracked files, respects .gitignore automatically
- Add USE_GIT_LS_FILES=1 flag (default enabled)
- Filter out *.standalone.js and *.min.js from checks
- Fallback to grep -r for non-git directories

Before: ~5-10 seconds scanning all files on disk
After: ~1-2 seconds scanning only tracked files

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-09-11 16:47:13 -04:00
Proudlock
bd48814021 Reorganize repository structure and clean up documentation
Clean up and organize the flask-shopdb repository for better
maintainability and discoverability:

## Changes

- Update .gitignore for build artifacts (publish/, data/, 122M total)
- Reorganize .claude/ into 6 subdirectories with README navigation
- Consolidate overlapping MCP setup guides into MCP-QUICK-START.md
- Move deployment docs from root to docs/deployment/
- Archive dated status reports to .claude/archived/
- Delete temporary files (logs, backups, empty reports, ~50K)

## New Structure

.claude/ organized into:
- setup/ - MCP, VM, Hyper-V, GitHub setup guides + scripts (17 files)
- plans/ - Feature plans and protocols (5 files)
- maintenance/ - Deployment checklists and reports (4 files)
- archived/ - Historical documentation (9 files)
- skills/ - Claude Code automation (5 files)
- workflows/ - Security audit workflow (1 file)

docs/deployment/ created with 6 deployment guides:
- LOCAL-DEV-SETUP.md, DOCKER-SETUP.md, DOCKER-PREBUILT-APPROACH.md
- PRODUCTION-READINESS.md, DEPLOY-TO-VM.md, README.md

Root directory cleaned to essential docs only:
- CLAUDE.md, README.md, CONTRIBUTING.md, CHANGELOG.md

Benefits:
- Clear navigation via README.md in each subdirectory
- No duplicate/overlapping documentation
- Large artifacts properly gitignored (won't bloat repository)
- Historical context preserved in archived/

See .claude/CLEANUP-SUMMARY.md for complete details.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-09-11 15:45:05 -04:00
benjamin.baker
58a3670529 Move migrations out of the api container into a migrate service
The dev stack ran schema work from the api entrypoint while the pull and
airgap stacks ran it from a dedicated migrate service. Same repo, two
different init paths, and the dev one was missing a step.

That gap was real, not theoretical: the first run of this service on a
stack that had been up for days created 13 default settings. The entrypoint
only ran `db upgrade` and `plugin upgrade-all`, never the seeds, so any
patch adding a permission or a setting reached pull/airgap sites and
silently skipped dev. endpoints.agentupdate was exactly that shape of
change.

Running init from the api entrypoint has two other problems. Under
`restart: unless-stopped` a failing migration crash-loops, re-attempting
partially-applied schema work and burying the original error under retry
noise; the migrate service uses `restart: "no"` so a failure exits once and
stays readable. And `plugin upgrade-all` was tolerated as a warning, so the
app would start with plugin tables missing - the shape of the
endpointlogcollections bug. Chained with && it now stops the rollout
instead.

No fallback path is kept in the entrypoint. Compose honours
service_completed_successfully even when a single service is named, so
`up -d api` still runs migrate to completion first - verified, api stays in
Created until migrate exits 0. A skip-flag would have meant the safe
behaviour depended on remembering to set a variable, and deployment here
goes through an install script that invokes compose, so there is no second
caller to protect. The entrypoint keeps only the database wait, which is
about this container's readiness to serve rather than schema state.

The seeds are all create-if-missing, so running them on every start is a
no-op once current: the second run reported 0 permissions, 0 settings and
scopes already up to date. Ordering in the chain is load-bearing - plugin
tables cannot be created before the plugins are installed, and seedscopes
only links permissions that already exist as rows, so seeds run last.

migrate carries the full app environment including CORS_ORIGINS even though
it never serves a request, because create_app() validates config before any
CLI command dispatches and the production config refuses to build without an
explicit allowlist. It mounts instance_data because bootstrap-plugins.sh
writes plugins.json there; it and api never run concurrently, so the shared
mount is not contended.

The Dockerfile's standalone `docker run` note now states that the image does
not migrate itself and lists the sequence to run first.
2026-09-10 15:50:36 -04:00
benjamin.baker
2cf46da55e Merge remote-tracking branch 'origin/wj' into wilm
# Conflicts:
#	scripts/check-naming-and-style.sh
2026-09-07 15:13:33 -04:00
benjamin.baker
0d5b9730e3 Add load test harness for pool and fleet sizing
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.
2026-09-07 12:39:53 -04:00
benjamin.baker
ea3739262b Fix style gate: file-level --exclude was cancelling every --include
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.
2026-09-07 12:39:40 -04:00
Proudlock
c9abb18dda fix: improve naming/style check to exclude external code
- Remove UTF-8 BOM from shopdb-admin.ps1
- Add --binary-files=without-match to grep (skip images/binaries)
- Exclude C# client code (.cs) from Python naming rules
- Exclude external directories: agent/, docs/, .github/, deploy/
- Exclude gitignored directories: _transfer/, instance/
- Exclude temporary files: scan.log, findings.sarif, report.md
- Exclude README.md from site-specific literal check (history docs)
- Scope banned-shorthand check to product code only (shopdb/, plugins/, frontend/src/)

The check now only validates actual Python/Vue/JS product code, not:
- Documentation examples (CONTRIBUTING.md shows violations as examples)
- External code (C# screensaver client, PowerShell installer)
- Binary files (images, bundles, restic binary)
- Build artifacts and temporary files

Fixes CI naming/style check failures

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-09-07 09:57:23 -04:00
Proudlock
d40f1a6a92 fix: replace unicode characters and fix finalize-restore endpoint
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>
2026-09-07 03:48:11 -04:00
Proudlock
5767765ada Fix Docker deployment: restore HTTPS, auto-run plugin migrations, install core plugins only
- Restore original HTTPS config in docker-compose files (gunicorn + gevent DOES support SSL)
- Add plugin migration step to docker-entrypoint.sh (fixes missing plugin tables on first deploy)
- Install only core plugins on bootstrap (backups, computers, endpoints, guides, knowledgebase, machines, network, notifications, printers, slides, tools, warranty)
- Set FLASK_ENV=production to disable Werkzeug debugger

Bug: bootstrap-plugins.sh installed ALL plugins but didn't run migrations, causing
'Table does not exist' errors. Now installs core plugins only and runs 'flask plugin
upgrade-all' after bootstrap.

Optional plugins (employees, geenforce, measuringtools, printedparts, usb) can be
installed later via Settings > Plugins.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-09-07 02:32:14 -04:00
Proudlock
783b5d3806 Fix backup script - remove all Python dependencies
Backup was mostly working but failing at the end due to Python calls.

CHANGES:
- Made manifest generation work without Python (bash JSON)
- Made check verification recording work without Python
- All Python calls now wrapped in 'command -v python3' checks
- Falls back to bash sed for JSON updates

RESULT: Backup completes successfully without Python
- Database dump: SUCCESS (24K)
- Minimal manifest: SUCCESS (bash-generated JSON)
- Restic snapshot: SUCCESS
- Repository verification: SUCCESS
- Script exit: SUCCESS (no more Python errors)

Backups now work in offline/corporate proxy environments with
zero downloads during Docker build.
2026-09-07 00:42:10 -04:00
Proudlock
f1ed2b19cd Fix backup system - offline restic package approach
PROBLEM: Corporate proxy (Zscaler) blocked ALL package downloads during
Docker build - Oracle Linux yum, Alpine apk, even with SSL certs.

SOLUTION: Offline package approach
- Pre-download restic binary on host (works outside Docker)
- Bake binary into repo at scripts/backup/offline-packages/
- Updated Dockerfile.backup to use offline binary (no downloads)
- Simplified: mysql:8.4 base + offline restic only (no Python/alembic)

RESULT: Backup containers build successfully behind corporate proxy

FILES:
- scripts/backup/offline-packages/restic_0.17.3_linux_amd64.bz2 (8.6MB)
- scripts/backup/Dockerfile.backup: Use offline package, no downloads

VERIFICATION:
- docker compose build backup: SUCCESS
- Backup watcher running: Polling every 30s
- Backup store running: restic REST server on port 8000

WORKING:
- Click 'Back Up Now' in UI
- Watcher detects sentinel file within 30s
- Backup executes automatically

The offline package can be checked into git for easy deployment at
other sites behind similar proxies.
2026-09-07 00:37:54 -04:00
Proudlock
3e36b54101 Fix backup feature for local Windows development
- Add --no-reload flag to run_dev.py to prevent env var loss
- Configure SHOPDB_BACKUPS_ROOT in .env for Windows path
- Create local backups directory
- Document local dev setup in backup README
- Fix: Flask auto-reloader was spawning process without env vars
2026-09-06 23:57:54 -04:00
Proudlock
5eb5c83b23 Document backup feature in scripts/backup/README.md
- Quick start guide for backup/restore
- Configuration options (local, SMB, S3)
- Troubleshooting common issues
- API endpoints reference
- Clarify empty list on first deployment is normal
2026-09-06 23:54:11 -04:00
Proudlock
316bc81a4c Add automatic plugin installation on first Docker deployment
- Add docker-entrypoint.sh: runs migrations and bootstraps plugins
- Add bootstrap-plugins.sh: installs all plugins (disabled by default)
- Update Dockerfile to use entrypoint
- Plugins can now be enabled via Settings UI after first deployment
- Idempotent: safe to run multiple times
- Solves issue where imported prod databases had tables but no plugin registry
2026-09-06 23:01:06 -04:00
Proudlock
a6af09858c Merge wilm: gevent migration, endpoint telemetry phase 2, backup/restore
Brings in 98 commits from wilm (origin/wilm @ 811e6ca61a):

Major features:
- Move API to gevent workers for better concurrency
- Endpoint agent startup jitter (prevents thundering herd)
- Complete backup/restore with restic
- Endpoint telemetry phase 2 (disk health, mapped drives, user context)
- PC Detail UI rework with new cards and tabs
- Dell Command actions and Corporate Tools tab
- Agent version column in Fleet view

Conflict resolution:
- Backup/restore: Accepted wilm implementation (10 files)
- Agent startup jitter: Accepted wilm v1.12.0
- PC Types list fix: Accepted wilm version
- Endpoint plugin domain clamping: Kept ours (defensive)
- Test files: Accepted wilm versions for consistency

Testing required:
- Verify gevent handles concurrent requests
- Test backup/restore functionality
- Verify agent startup jitter timing
- Test endpoint telemetry collection
- Validate PC Detail UI changes

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-09-06 21:23:42 -04:00
993353dfc0 Endpoint telemetry, PC Detail rework and a checked backup/restore path 2026-09-06 18:53:11 -04:00
benjamin.baker
d6bd7b5f54 Fix endpoint user-context collection running in the wrong session
Mapped drives reported Unavailable on endpoints where the operator could
see them fine. The data path was intact; the collection was running under
the wrong token. Mapped drives are per logon token, so an elevated probe
enumerates a different drive table than the interactive user.

Collection:
- Pin the user-context spool path so the probe and service agree on one
  location, and persist/overwrite it instead of deleting on read.
- Refuse to collect while elevated (exit code 2) rather than emitting a
  confident wrong answer, and stamp elevated/username on the spool.
- Reject spools stamped elevated or from an unexpected account on read,
  so an admin run cannot masquerade as valid.
- Log netuse/smbmapping/userlistener counts so each run leaves evidence
  of what it actually saw.

Install and launch context:
- Move collection to a 'ShopDB user-context probe' scheduled task under
  S-1-5-32-545 at RunLevel Limited, the only mechanism that reliably runs
  in the operator's own session. Verify registration and record it in the
  install ledger.
- Stop starting the tray as a child of the elevated installer; explorer.exe
  de-elevates without changing ACCOUNT, so an admin install produced an
  admin tray. The Run key only fires at logon, which left the tray dead on
  a machine already signed in, so add a transient 'ShopDB tray first launch'
  task that starts it once in the existing session and unregisters itself.
- Hide the console window during --collect-usercontext. The agent is a
  console exe; the tray used to mask this with CreateNoWindow, but the
  scheduled task invokes it directly and flashed a window every cycle.

Installer edits are in _install_script() in plugins/endpoints/api/siteagent.py,
which generates the install.ps1 that actually ships. The standalone
agent/installer/install-shopdbagent.ps1 was never shipped once the package
moved to the UI builder and is deleted here to stop the divergence that
caused an earlier fix to land in a file no endpoint ever ran.

Versions: agent 1.10.1, tray 1.3.0. These share the pinned spool path and
must be deployed together.

Pre-existing test failures unrelated to this work (8, identical on a clean
tree): docs publishable/versions, plugin contract symlink scan, import
guard tamper check, security headers upload sandbox, and three endpoint
timestamp tests.
2026-09-05 17:32:03 -04:00
benjamin.baker
e00bb44d6b merge storage and disk health into one card with two sections
Volume capacity and physical drive health were two adjacent cards, and the operator reading a full disk is usually the operator reading a dying disk in the same minute. They are now one Storage card with two sections. Visual grouping only - the two tables share no key, storage being per volume and health per physical device, and one drive can back several volumes or none the operator sees. Nothing is reconciled across the sections; this is not the mapped-drive merge where two sources described the same drive and the worse status won. The tabs render type was the only sections-in-one-card mechanism and emitted label/value fields with no table path, so it now takes table sections too. That is the renderer every plugin uses, so both behaviour changes in it are mutation-tested. Two things only surfaced on contact: the empty-section filter had to grow a table branch, because a table section declares columns up front and dropping it when it has no rows takes its tab with it and reads as no disks rather than nothing reported; and full-width span was keyed off the declared render type, but a tabs panel holding a six-column table needs the same span and that is only knowable from fetched data. The diskhealth route stays. It no longer backs a panel, but it is a stable read for callers outside the renderer and removing it for a UI-only change would break them.
2026-09-05 07:05:30 -04:00
benjamin.baker
b973561cb7 Gate restore on the dump's data revision, not the code head
A restore could load a database dump whose schema was older than the running
code. The manifest stamped schemarev from the code image on disk, so the
compatibility check compared the code against itself and always passed. A set
captured from a database at 7d37_import_batches_and_journal restored under
code at 7d41_drop_guest_tools_secrets, leaving the data behind the code: every
query on a table the newer migrations touched failed with 1054 Unknown column,
which surfaced as empty configuration-policy dropdowns and a failure to load
data on edit PC.

The authoritative signal is the alembic_version of the database that was
dumped, so manifests now carry datarev alongside schemarev, backup.sh reads it
from the database and warns when it has drifted from the image, and the gate
asks whether the running code knows that revision. Data behind the code is
allowed because it can be rolled forward; data ahead of the code is refused
because it cannot.

Rolling it forward has to happen somewhere with an app context. The backup
sidecar is mysql plus restic plus alembic only and has no Flask, so restore.sh
loads the dump and parks the run in an upgrading phase behind an
.upgrade-requested sentinel rather than declaring a success it cannot verify.
The api container owns the Flask CLI and already runs these exact idempotent
commands on every deploy, so a finalize endpoint there runs db upgrade plus
plugin upgrade-all and stamps the terminal phase. The UI drives it on seeing
the upgrading phase, so recovery needs no operator intervention.

The live simulation runs one codebase against itself, so its dump is always at
the same revision as the code restoring it and it can never reproduce this
drift. The gate therefore gets a standalone tester that builds throwaway
migration chains and dumps offline, invoked as a simulation preflight. It
caught a real bug in the legacy dump-parse fallback, where the revision was
matched loosely enough to return the alembic_version table name instead of the
value it holds.
2026-09-04 15:53:58 -04:00
benjamin.baker
3c9321f16c Add developer-only restore simulation harness
Some checks failed
CI / backend (push) Has been cancelled
CI / naming (push) Has been cancelled
CI / gates (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / lean-build (push) Has been cancelled
FOR DEVELOPER USE ONLY. Adds a live restore round-trip diagnostic that fingerprints every base table via CHECKSUM TABLE, backs up and restores through the real sentinel path, and proves all 110 tables return to backup-time state.

Documents expected drift so future runs are not misread: apitokens last-used churn, external endpoint-agent writes (computers/endpointstates/events/applications) that cannot be quiesced, and that live asset-row deletion is not permitted under RESTRICT/CMMC. Restore remains a full drop-and-recreate replace, so a missing or corrupted asset is still fully restored - verified, no restore.sh change needed.

Writes simulate-restore-results.txt to the script dir and ships a lightweight README with the testing cadence and parameters.
2026-09-04 05:01:03 -04:00
benjamin.baker
debe1fce69 it is risen - add restore progress and backup/restore history
The watcher scripts now write live status and append-only run history onto the shared volume; the api exposes them via /shopdbbackups/status and /history. The restore modal shows an indeterminate spinner with phase labels, and two cards list backup and restore run records. Timestamps render in UTC to match the Zulu-stamped set ids, the history directory is excluded from the set list, and a stale-status guard stops a previous run's done phase from collapsing the spinner on the next restore.
2026-09-03 20:43:42 -04:00
benjamin.baker
7e4469c44e Wire restore into the backup watcher and fix backup timestamps
The api queued .restore-requested but nothing consumed it, so restores silently did nothing. The watcher now services the restore sentinel by running restore.sh, and its instance_data mount is read-write so restore can lay files back down (the manual one-shot stays read-only). Backup stamps use a colon-free UTC form that Date could not parse, so the UI showed raw strings; normalize them before formatting.
2026-09-03 20:26:01 -04:00
benjamin.baker
19071ec6c6 Add opt-in backup overlay with third-container restic store
Backups are per-site and not part of the base stack, so they ship as a separate compose overlay with an isolated restic REST server the app can only reach through the backup utility (no shell or db creds). The sidecar image trusts the corporate proxy root CA at build time so its Oracle yum, github, and PyPI fetches succeed behind TLS interception; shell scripts are pinned to LF via .gitattributes so the container entrypoints do not die on a CRLF shebang.
2026-09-03 20:06:45 -04:00
benjamin.baker
d5d823fd8b Merge remote-tracking branch 'origin/wj' into wilm 2026-09-03 17:35:48 -04:00
ff9ae693a6 Remediate the 2026-09-03 feature-test findings 2026-09-03 17:14:53 -04:00
benjamin.baker
ab949f8bec Merge remote-tracking branch 'origin/wj' into wilm
Some checks failed
CI / backend (push) Has been cancelled
CI / naming (push) Has been cancelled
CI / gates (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / lean-build (push) Has been cancelled
2026-09-03 13:46:17 -04:00
ddf41c3e65 Make a partly-undone import retryable, and refuse a plan it cannot deliver 2026-09-03 09:16:01 -04:00
b0bd73f62d Import the wilm agent store, WINC adoption work and batches CLI, with four fixes 2026-09-03 08:16:38 -04:00
benjamin.baker
17825ec8f1 Add WINC batches CLI and a CLI how-to README
The web import panel can list applied/undone batches, but the CLI had no equivalent, so an operator working from the shell had no way to find a batchid to feed into the undo command. Add a read-only batches command that reuses the same importbatches query, and a short README documenting the four WINC commands and their switches.
2026-09-03 08:07:01 -04:00
benjamin.baker
2998e35ddf Add reversible Maximo/WINC machine-adoption import
Machine data arrives as a Maximo asset export (validated against the WINC
site) that must update existing machines in place, not create canonical
per-table rows, so it does not fit the ShopDB-native/Azure/Thor CSV adapter
path. Bulk edits to live machines also need an escape hatch, so the whole
commit is journaled and can be undone as one batch.

Add a dedicated dry-run analyzer, approval packet, resolver/suggest helpers,
and a reversible writer/undo backed by a new importbatches/importjournalentries
schema (with migration). Expose preview/commit/batches/undo settings routes and
a matching frontend source card and panel, and surface the batch/journal
contract through shopdb.api for plugins.
2026-09-03 05:04:22 -04:00
benjamin.baker
27ccf8962e exclude gitignored devnotes from the naming/style gate
The gate walks the working tree with grep, which does not honor .gitignore, so it scanned devnotes/ (local scratch notes, already gitignored and never committed) and failed on pasted logs full of em-dashes and arrows. Exclude devnotes/ the same way venv, node_modules, and dist are already excluded.
2026-09-02 18:39:23 -04:00
benjamin.baker
ebece2d3bd Merge origin/main into align-wilm-main (align wilm with Cameron's latest)
Bring main's endpoint hardening, operator-report validation, launch-path
policy, and theme-variable CSS into wilm. Channel model reconciled to this
repo's shipping (stable=release tag, edge=main trunk) while keeping wilm's
remote-BuildKit publish step and GHCR pull-based launcher.
2026-09-02 17:41:03 -04:00
bb733f1332 Merge the wilm work, act on the five-agent review, verify the launch-path policy on Windows 2026-09-02 16:15:53 -04:00
benjamin.baker
d0e9f80287 distribute prebuilt channel images via GHCR instead of building on pilots
Pilots behind the inspecting proxy cannot reliably build the frontend from source; npm's many small fetches get reset mid-install. Build the image once in CI on the clean runners and have pilots pull the finished channel image. One resumable image pull survives the proxy where hundreds of npm requests do not, and a pilot now needs only Docker.
2026-09-02 09:34:40 -04:00
benjamin.baker
3cbba2df14 re-clone when git reports dubious ownership on the working copy
a clone first created from an elevated prompt is owned by Administrators; a
later normal-user run then fails every git op with 'dubious ownership'
(exit 128), which the launcher misreported as an auth error. detect that
case and remove the clone for a clean re-clone.
2026-09-02 08:54:37 -04:00
benjamin.baker
aff3bcb8a0 keep launcher cleanup from aborting on docker stderr warnings
docker/compose emit warnings on stderr (e.g. nothing to remove), which
tripped ErrorActionPreference=Stop and killed the run before the clone.
soften the preference around the best-effort cleanup steps.
2026-09-02 08:18:18 -04:00
benjamin.baker
30544988ce reset interrupted runs before retry in the dev launcher
proxy timeouts can kill a build mid-flight, leaving orphaned containers on
the channel ports, dangling build cache, and half-written clones. clean
those on every start, scoped to the channel and keeping the DB volume, so a
re-run starts from a clean slate.
2026-09-02 08:16:13 -04:00
benjamin.baker
58040164c2 auto-detect and stage corp TLS-inspection root CA before docker build
GE/Zscaler proxy inspection breaks npm ci silently, leaving node_modules
incomplete and vite missing at build time. Detect the root CA from the
host's chain to the npm registry and stage it as corp-root-ca.pem so the
Dockerfile trusts it; harmless on clean networks.
2026-09-02 07:58:31 -04:00
benjamin.baker
30b089bc8a replace web iex launcher with a save-and-run script
The iex (irm ...) one-liner could never work against a private repo: raw.githubusercontent.com returns 404 without a token, and a git operation's Credential Manager auth does not apply to a plain irm. The prior token-passing workaround (get.ps1, 717387f) asked pilots to paste a PAT, which they will not do and may not have.

Start-ShopDBDev.ps1 is a downloaded FILE, not a web pipe, so only its git clone/fetch need auth - and those go through Git Credential Manager using the login pilots already have from cloning this repo or signing in via Visual Studio / VS Code. No token, no paste. It takes -Channel stable|edge and hands off to dev-bootstrap.ps1. DEV-BUILD-TEST.md is rewritten for the save-and-run flow with a GCM-seeding fallback.
2026-09-02 07:43:06 -04:00
benjamin.baker
717387f665 authenticate the web launcher against the private repo
raw.githubusercontent.com returns 404 for a private repo without a token, so the anonymous iex one-liner could never fetch get.ps1, and even once fetched its git clone of the same private repo would fail. get.ps1 now reads a GitHub PAT from SHOPDB_TOKEN (or GITHUB_TOKEN) and uses it for the clone/fetch; the raw fetch is authenticated by irm -Headers in the one-liner. The token is kept off disk: origin is reset to the clean URL after clone, and the update path fetches by explicit URL rather than a tokenized origin.
2026-09-02 07:33:30 -04:00
benjamin.baker
68c33ee896 add channel-aware distributable installers and web launcher
Follows the config-driven-badge decision through to packaging: a single channel-parameterized builder emits two distinct installers (stable from main, edge from dev-be) rather than two drifting scripts. Each package ships the image bundle, the airgap+channel compose templates, a pre-filled .env, and a one-command site installer.

Secrets ship as CHANGE_ME_* sentinels and Install-ShopDB.ps1 refuses to start until every one is rotated, so a distributable can never come up on shipped credentials. docker-compose.airgap.yml now threads SHOPDB_ENV_LABEL through the shared app-env anchor, otherwise a distributed edge stack would run the badge-capable image but never light the badge.

get.ps1 is the web launcher that makes the iex one-liners work: dev-bootstrap.ps1 operates on a local clone, so get.ps1 clones/updates the channel branch and hands off. docs/DEV-BUILD-TEST.md is the short reviewer handout with the two one-liners for Cameron to try.
2026-09-02 05:41:25 -04:00
benjamin.baker
0524ce7751 add config-driven channel label and dev-workstation bootstrap
A bleeding-edge container running next to a stable one on the same dev box is easy to confuse, and a branch-only badge would force perpetual rebase/cherry-pick drift between main and dev-be. Instead the badge is config-driven: the settings endpoint injects a synthetic env_label from SHOPDB_ENV_LABEL, the sidebar renders a red DEV tag only when it is non-empty, and stable simply leaves it unset. Same code on both channels, no divergence.

dev-bootstrap.ps1 makes a fresh Windows box turnkey: it checks git/Docker (winget-installs with a prompt), verifies the engine is actually running, then builds and starts an isolated per-channel stack so stable and edge coexist on distinct ports, projects, and volumes. docker-compose.channel.yml is a thin overlay so the base compose file stays channel-agnostic.
2026-09-02 05:27:01 -04:00
benjamin.baker
ab4b9d7ec9 add operator issue reports for endpoint agent
Operators had no way to flag a problem PC from the tray. This adds a report path: the tray captures a reason, free-text note, and optional screenshot and the agent uploads it on the collector token to a new operatorreports table linked to the asset. Ships the endpoints 0009 migration so the table exists before the code queries it; the running DB had drifted behind the code and returned HTTP 500 on upload.
2026-09-01 19:39:32 -04:00