Commit graph

9 commits

Author SHA1 Message Date
le king fu
f81a80e325 fix(agent): anchor the backup rotation on the newest backup, not today
All checks were successful
CI / verification (pull_request) Successful in 32s
The rotation cutoff was TODAY minus 29 days. When the VPS stops
producing (dead cron, failing pg_dump) or this machine's clock jumps
ahead, the last off-site copy aged out after 30 days, the pass exited 0
and the report said ok on a directory it had just deleted. The cutoff
now comes from the newest dated directory held here, which is never
removed.

Review suggestions:
- --prune-empty-dirs: a dated directory with no .age file is not
  created, and one found empty logs a warning;
- files counts the dated directory only (-maxdepth 1), not partial
  files left in .rsync-partial/;
- an EXIT trap removes the header file holding the ingest token, with
  TERM/INT/HUP turned into an exit so a stop by systemd runs it.

Refs maximus/la-compagnie-maximus#220

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:03:48 -04:00
le king fu
b87b3d7253 fix(agent): run the backup pull under busybox, catch an empty filter
All checks were successful
CI / verification (pull_request) Successful in 31s
The CI runs the tests in node:22-alpine, where 9 of the 12 cases failed:
no ssh on PATH, and busybox has neither `find -printf` nor
`date -d "... days"`. The newest dated directory is now found by a loop
over the glob, and the rotation cutoff by epoch arithmetic, which GNU
date and busybox both read. The tests add a fake ssh, and a fake flock
where the host has none.

The rotation_failed case failed the pass through a read-only directory,
which root ignores: it now uses a fake rm.

Review suggestions: the rsync argv must put --include=*/ before
--exclude=* (without it nothing crosses and the pass still says ok), and
a case runs the real rsync through the fake ssh when rsync is present.
A pass whose newest directory is not today's logs a warning.

Refs maximus/la-compagnie-maximus#220

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:56:05 -04:00
le king fu
59b963220d feat(agent): daily pull of the encrypted PostgreSQL backups
Some checks failed
CI / verification (pull_request) Failing after 28s
POSIX sh agent run by a systemd user timer (Persistent=true, retries on
failure): rsync over the classic sshd on 100.86.30.21:2222 with a pinned
host key, .age files only, 30-day rotation that fails loudly, and a
report to POST /backups/pull when the ingest token is configured (sent
through a header file, never argv). Never decrypts.

Refs maximus/la-compagnie-maximus#220

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:18:17 -04:00
le king fu
cf72292967 feat(agent): one-shot backfill of the Claude usage history from local transcripts
agent/claude-usage/backfill-history.js runs by hand from the repo (never
from the cron install, the agent is unchanged). It rebuilds the closed days
with the agent's own aggregate() on past windows and writes { days } to the
--out file, which the server merges with `claude-usage-history.js --import`
(an already archived date wins).

Each 30-day window keeps only its 28 inner days and the next steps back 28
days, so a request straddling a window boundary is deduplicated by the
window that keeps it and counted once. The first window stops at D-2. The
walk is bounded by --max-windows (default 24) rather than guessed from file
mtimes, which say nothing of the oldest line a resumed session holds.

It never reads the credentials file, opens no connection, writes only the
--out file (mode 600, never overwritten) and prints counts only.

Refs #55

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:44:45 -04:00
le king fu
ca33f10b85 docs: document the read-error freeze rule and count the #54 cases
All checks were successful
CI / verification (pull_request) Successful in 31s
Refs #54

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:30:20 -04:00
le king fu
7f01e6afe5 docs(agent): document the claude-usage agent install and its guarantees
Resolves #50

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:26:15 -04:00
le king fu
523d90bf7c fix(hosts): require 3 loadAvg values and sweep orphan temp files (#25)
All checks were successful
CI / verification (pull_request) Successful in 21s
CI / verification (push) Successful in 20s
Follow-ups from the review of the workstation monitoring stack (#18-#23):

- loadAvg must hold exactly 3 finite numbers (1/5/15-min averages). The
  admin card joins them, so a shorter array rendered a partial line and a
  longer one was silently truncated; both are now a 400, and a stored
  snapshot off that format degrades to neverSeen on read.
- sweepStaleTempFiles() runs at startup, before listen, and removes the
  `.<id>.<pid>.<ms>.tmp` files a process killed mid-write leaves behind,
  once older than an hour. Exact name format only, regular files only
  (lstat), best effort: a missing HOSTS_DIR stays silent, other failures
  are logged and never stop the server.
- Document that cleanString keeps control characters on purpose (safety
  rests on the consumer escaping at render time), and that frozenRequest
  does not freeze `new Date()`, which stamps receivedAt on POST.

Fixes #25

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:30:07 -04:00
le king fu
e181a9691c fix(review): close the three gaps found in stack review
Follow-up to the /pr-review pass on PRs #18-#22. Three findings, none of
which changed behaviour, all of which weakened a guarantee the stack was
supposed to provide.

1. auth.test.js carried "any new route must be added here" but /hosts was
   never added when #13 introduced it, so no test asserted GET /hosts -> 401
   on a missing header. The drift class the file exists to catch slipped on
   its first outing. ROUTES now covers /hosts, and a dedicated block pins
   both directions of the token separation: a read token cannot write, an
   ingest token cannot read.

2. ingestOversized() folded any transport error into 413, so the four
   oversized-body tests would have stayed green if the server had stopped
   writing the status and merely killed the socket - on the one path where
   "a status, not a dead socket" is the whole client contract. Transport
   errors are now surfaced instead of swallowed.

3. HOST_AGENT_TIMEOUT_MS was read by push-metrics.js but never exported by
   run-push.sh, so setting it in the documented env file did nothing. Now
   exported and documented.

Also drops a claim from agent/README.md that the review proved false: the
ingest/read token split buys no containment on the ThinkPad, which already
stores HEALTH_TOKEN in cleartext for defenseur-auto. Losing that laptop
compromises both, so they rotate together.
2026-08-16 14:24:43 -04:00
le king fu
e24f5b6f00 feat(agent): push workstation metrics to /hosts/<id> from cron
Zero-dependency local agent: collect one snapshot through the shared
metrics.js, POST it once to /hosts/<id>, exit. Cron runs it every five
minutes. Nothing is queued and nothing is replayed — a heartbeat from five
minutes ago describes a machine that no longer exists, so a failed push is
logged and dropped rather than spooled.

run-push.sh sources ~/.config/maximus-host-agent.env (mode 600), refuses to
start when HOSTS_API_URL, HOSTS_INGEST_TOKEN or HOST_ID is missing, and never
enables shell tracing: the script is meant to be piped into `logger`, where
`set -x` would echo the ingest token into /var/log/syslog and journald for
good. Same reason the failure path reports only err.code and the HTTP status —
never an error object, request options, headers, or a response body. The cost
is accepted: a 400 says the payload was rejected, not why.

50 tests, including two that spawn the real process against a failing server
and scan its actual stdout and stderr for any five-character fragment of the
token. The payload is pinned against the server's own sanitizeSnapshot(), and
one case pushes a real collectMetrics() snapshot through the real ingestion
handler, so a drift between agent and server turns a test red instead of
producing a 400 at 3 a.m. on the ThinkPad.

agent/ stays out of the Docker image: the COPY line is untouched, and a test
pins that it copies files one by one and never names the directory.

Installing on a workstation — frozen copy, env file, crontab line, dry run,
and why two machines must never share a HOST_ID — is documented in
agent/README.md. The install itself belongs to the commissioning issue.

Resolves #15
2026-08-16 12:21:59 -04:00