Skip to content

v1.3.0

Choose a tag to compare

@eengelking eengelking released this 22 Oct 20:27
· 20 commits to main since this release
1edc179

Added

  • The reporter helper forwards only reporter routes, and no regex can be made to backtrack on a long input (#668, part of #577). The helper that carries the mod's calls over mutual TLS now answers 403 to any path or method that is not one of the reporter calls, and builds the request it sends from the matched route. Twelve regexes that could take quadratic time on a long run of slashes or blanks (a trailing-slash trim on a request path took 3.4 s on 100,000 slashes) are replaced with string handling, and the four confirmation buttons on Admin > Certificates and People take focus in code instead of with autoFocus.

  • Failed requests are limited per address (#667, part of #577). Ahead of every /api route, one address may make 300 requests a minute that end in a response of 400 or more. The next one gets 429 with Retry-After until the minute is over, and the first refusal in a window is logged as auth.throttled. Successful requests are never counted, and PODIUM_FAILURE_LIMIT changes the limit. It is a flood brake on the paths that check a token or a certificate, which CodeQL reported as unlimited (js/missing-rate-limiting); the wrong-secret lockouts are unchanged. New dependency: express-rate-limit.

  • A built-in TLS guide: browser trust, the reporter, expiry and the threat model (#590, part of #577). docs/SERVER.md has a new "Built-in TLS" section that walks podiumctl start --tls, the two ports, that no browser needs the CA, the reporter config, the HTTP switch, what to do when a person's certificate, the serving certificate or the CA expires, and what TLS stops and does not stop. docs/REPORTER.md has a table of what each reporter status-line message means, and the User guide's "Something is wrong" table points to both. Uploading your own certificate is not supported. The guide was walked on a throwaway server with Claude Code sessions in tmux, which found three reporter defects, filed as #645, #646 and #647. docs/DECISIONS.md 42 no longer says nothing is built, and docs/SERVER.md says a wildcard bind adds no address to the serving certificate.

  • The Certificates page says why Rotate CA is off for a CA made before rotation with an overlap (#638, part of #577). OpenSSL refuses the chain [leaf, cross] under a CA with pathlen:0, which is what the first TLS builds made. For such a CA the confirmation now explains that it cannot be rotated with an overlap, disables Rotate CA with that reason beside it, and leaves Rotate CA and end the overlap now, which replaces it with a CA that can take a planned rotation next time. GET /api/admin/certificates has a new canOverlap field.

  • Admins can stop reporters from connecting over HTTP, with a warning while they can (#589, part of #577). Admin > Server > Certificates has a new section, Reporters over HTTP, with the switch Allow reporters to connect over HTTP (tls.allowReportersOverHttp). It is on for a server that already had reporters when TLS was first turned on and off for a fresh one, and a warning line shows in both states. Turning it on is immediate. Turning it off asks first, names how many reporters are connected over HTTP now, and says the console on loopback keeps working. With it off, the HTTP listener answers 403 to the routes a reporter calls (/api/fleet/report, /hook, and the peers and respond routes) from any address but loopback, logs tls.http_refused, and shows the source in the Reporters page's refused list; a reporter that has a config uses HTTPS and is unaffected, and the console is never gated. A change is written to the Admin log as tls_http_changed. podiumctl and the Containerfile already publish both ports (#580).

  • Rotate the CA with a cross-signed overlap, and retire the old one (#588, part of #577). On Admin > Server > Certificates, Rotate CA… asks first and then makes a new CA, signs a cross certificate for it with the old one (120 days), and serves [new leaf, cross certificate], so a reporter that holds only the old CA still verifies the server. Both CAs are trusted for client certificates. A reporter that renews during the overlap gets the new CA and the cross certificate in the renewal response (rotation) and adds the CA to its config file only if the cross certificate verifies under a CA it already holds and the new certificate under the new one; it never replaces a CA. The page shows a blue "rotation in progress" band and a Previous certificate authority row with "N of M reporters still use it", and Retire old CA… names that count, refuses those reporters at once and closes the connections that came in on the old CA. Rotate CA and end the overlap now is the hard cutover after a leaked key: no overlap, nothing delivered, every reporter needs a reissued config, and every open connection with a client certificate is closed. The listener, the serving renewer and the certificate check now read one list of trusted CAs, which closes the T5 review's concern that a certificate could pass TLS and be refused for its issuer. A new CA has pathlen:1; a CA made earlier (pathlen:0) can only rotate by ending the overlap. New routes POST /api/admin/certificates/rotate-ca and /retire-ca; GET /api/admin/certificates adds rotation and overlapDays. Mockup: mockup/podium-tls-certificates.html (#573), state "CA rotating".

  • Admin > People shows each credential, one Actions menu per row, and the reporter config after Create user (#587, part of #577). The table has a Reporter certificate cell and a Console token cell, each with its own badge and a note that opens on hover and on focus (the date and what happens next for the certificate, pdm_<key id> for the token); the Status column is gone, the Group select sits under the name and "created" under Last report. Every row has one Actions menu, grouped Console token, Reporter certificate and Person, with Up, Down, Home, End and Escape; a rotate, a revoke, Revoke certificate, Revoke all and Reissue config ask first in a full-width row under the person. After Create user, Reissue config or a token rotation the show-once box gives the token and the file reporter-config-<name>-<epoch>.pem with Download reporter config, once; Download certificate gives the public certificate and the CA, never a key. GET /api/admin/people adds tls, graceDays and each row's certificate (notAfter, revoked). With TLS off the certificate cell says so and the certificate actions are disabled. Revoke token still revokes the person's certificate too (SPEC 7.7), so its confirmation says the reporter is refused; the mockup's "Their reporter keeps working" for it is not followed.
    Standards: the row Actions menu (.menu, .menubox, .mi, .mgh, .msep), the confirmation in a full-width row under a table row (tr.confirmrow), the show-once box's second part (.once h3 and ol.plain), and the People table's two credential cells with the Group select under the name; approved with the mockup on #573 (decisions 11, 15 to 19, 22 and 27).

  • Admin > Server > Certificates and the certificate pill in the header (#586, part of #577). The new page shows the CA and the serving certificate with their dates and a status that is a shape, a word and a color, a band at the top (blue, orange or red as the first of them runs out), the reason a renewal failed in a critical row, the CA fingerprint with a copy control and Download CA, and the serving certificate's names in a small dialog. The serving certificate's lifetime and renew-before days, a person's certificate lifetime and grace days save when the field is left, with a refused value shown in place, and the header countdown has its own switch. Renew now works from the page, the certificates Podium kept are listed, and an expired CA lists the steps to restore access. The header shows a pill after the connection pill, blue at 30 days or fewer, orange at 10, red at 5, and "Certificate expired", for whichever of the CA and the serving certificate expires first. It links to the page and has no X; the switch hides only the countdown, and a failed renewal or an expired certificate always shows. It reads the new public GET /api/tls/status, and GET /api/admin/certificates now also returns the CA, the header setting and a person's certificate lifetime and grace (reporter). Rotate CA... is on the page but disabled until the rotation lands (T10); People, the HTTP switch and the rotation state follow in T9 to T11. Mockup: mockup/podium-tls-certificates.html (#573).
    Standards: header status pills .pill.soon (blue) and .pill.serious (orange), and a status pill that is a link (a.pill); Admin table badges .badge.soon, .badge.serious and .badge.bad with an icon inside a badge; the red band row .band.crit and the band row's text layout on an Admin page (.band .bt, .band .bd); .nset and .note for a grouped list and a quiet note in a small modal; a focus ring on the tooltip trigger (.tip:focus-visible); and td.nw and the CA fingerprint line's wrap. Orange text uses the existing band color (--serious mixed with --ink), since no --serious-text token exists. Approved with the mockup on #573 and in the decisions comment on #323 (#323 (comment)).

  • A person's certificate can be renewed once an hour (#612, part of #577). POST /api/reporter/renew answers 429 with Retry-After and the code TOO_SOON while the person's newest live certificate was issued less than an hour ago, before it starts openssl or adds a row, so a looping or hostile client holding a valid certificate cannot fill person_certificates or keep the server busy. The limit is fixed at an hour, not a setting. The helper treats a 429 as a wait, not a refusal: the request still goes out, the file is left alone, and it asks again after the Retry-After time (never sooner than a minute). A refused renewal is logged as tls.reporter_renew_refused with the code.

  • The reporter renews its own certificate, and the server checks the grace (#585, part of #577). A person's certificate lasts tls.reporter.lifetimeDays (90; 30 to 365) and the helper renews it when 30 days of it are left, at start, before a request, and every 10 minutes while a session idles. It makes a new EC P-256 key and sends only the public key to the new POST /api/reporter/renew, which is answered to a client certificate and nothing else. The server signs that key for the same person and returns the certificate and the CA(s). The helper checks the answer (our key, signed by a CA the file already holds), then rewrites the config file in one rename at mode 0600 under a lock file (<file>.lock, taken over after a minute if its owner died), so several sessions renewing at once leave one valid file and the second takes the first's certificate. The old certificate stays live until it expires, and each is its own row. An expired certificate may still renew for tls.reporter.graceDays (30; 0 to 90) after it expired, and the status line says it is renewing. The server never trusts CERT_HAS_EXPIRED or a serial for that: the certificate's bytes must equal the stored one and its signature must verify under the CA key that issued it (X509Certificate.verify), so an expired forgery with the same subject and serial, from a fake CA or self-signed, is refused. Past the grace, or for a revoked certificate, the status line and /podium say to ask an Admin to reissue the config. Both settings can be saved with PUT /api/admin/certificates (the page that shows them is T8, #586). A CA arriving in the renewal response is T10 (#588); this step never adds a CA the file does not already hold. PODIUM_RENEW_BEFORE_SECONDS and PODIUM_RENEW_CHECK_SECONDS change the helper's thresholds for tests.

  • The reporter connects over TLS with the person's certificate (#584, part of #577). When a reporter config file exists (PODIUM_REPORTER_CONFIG, or the newest reporter-config-*.pem in ~/.podium/ by its issued-at line), the mod starts a small Node helper, hooks/helper.mjs, once per session and sends its requests to it over a Unix socket with $.http.fetch(..., { socketPath }). The helper makes the HTTPS connection with the CA and the person's certificate from the file, so nothing is installed and NODE_EXTRA_CA_CERTS is not used. The server address and the name come from the file. It forces certificate verification on, clears NODE_TLS_REJECT_UNAUTHORIZED and NODE_OPTIONS, keeps one socket per session in a 0700 folder with mode 0600, prints ready when it listens, exits when its parent dies and removes only its own socket, reports a failure in an X-Podium-Helper-Error header, and aborts the response on an upstream error mid-body. It refuses a config file that group or others can read. It re-reads the same file only for the same person and keeps the last good copy when a read fails. A /clear does not stop it. The reporter finds node by PODIUM_NODE, then its PATH, then the usual places (Homebrew, /usr/local, nvm, Volta); a missing node, a loose file mode, a file for another name, an expired certificate, a revoked one, or a server certificate that is expired, for another name or signed by another CA each show in the status line and in /podium instead of a bare "offline". It never sends a credential over http:// when the address was https://. With no config file nothing changes.

  • Each person gets a certificate, and an Admin can revoke it (#583, part of #577). With TLS on, creating a person also returns their reporter config file once, reporter-config-<name>-<epoch>.pem: the CA, a 90-day clientAuth certificate (subject CN=pdm_<key id>, O=podium, never the name) and its private key, with the server address in the header. Podium keeps only the certificate, by SHA-256 fingerprint in the new person_certificates table (migration 23), never the key. The HTTPS listener now asks for a client certificate with the Podium CA as ca; a request that carries one is attributed to that person, checked on every request, so a revoke reaches a connection that is still open. A forged certificate (same subject and serial from another CA, self-signed, expired, or the serving certificate) is refused with a 401 even when a valid token is sent with it. New Admin routes (404 while TLS is off): POST /api/admin/people/:id/certificate/reissue (a new file, once; the earlier certificate is revoked), POST /api/admin/people/:id/certificate/revoke, POST /api/admin/people/:id/revoke-all and GET /api/admin/people/:id/certificate.pem (the certificate and the CA, never a key). Revoking only the certificate leaves the token and the console sign-in alone; Revoke token also revokes the certificate, so it and the new Revoke all stop both. Both now refuse to revoke the last active Administrator. Fixes a bug waiting in #582: a renewed serving certificate dropped the CA list, so client certificates would have stopped verifying after the first renewal. The reporter that uses the file is T6 (#584) and the screens are T9 (#587).

  • The serving certificate renews itself, with two Admin settings (#582, part of #577). With TLS on, the server checks the serving certificate at start and once a day and renews it when fewer than tls.serving.renewBeforeDays (30; 7 to 90) remain, from the same CA and for the same names, with a lifetime of tls.serving.lifetimeDays (365; 30 to 825). The new certificate is swapped into the running listener with setSecureContext, so nothing restarts: an open connection finishes on the old certificate and a new one gets the new. The replaced pair goes to tls/previous/ and the last 3 are kept. A failed renewal is logged as tls.renew_failed, kept for the Admin API and retried at the next daily check. New Admin routes (404 while TLS is off): GET /api/admin/certificates, PUT /api/admin/certificates (a value outside its range is a 400 that names the range; each change is written to the Admin log) and POST /api/admin/certificates/renew, which also takes up a changed bind address or PODIUM_ALLOWED_HOSTS. The Certificates page that shows this is T8 (#586).

  • The server makes its own CA and serving certificate and serves HTTPS (#581, part of #577). With PODIUM_TLS=on (podiumctl start --tls), the first start creates a CA (10 years) and a serving certificate (1 year) in tls beside the database (/data/tls in the container) with openssl, EC P-256, the folder mode 0700 and keys 0600, then opens HTTPS on port 4318 (PODIUM_TLS_PORT) next to HTTP on 4317. The certificate names are localhost, 127.0.0.1, the bind address and PODIUM_ALLOWED_HOSTS, and the Host check applies on HTTPS too. A CA that exists is never overwritten: a half-present or unreadable one, a missing openssl or a bad or busy port logs tls.failed and leaves HTTP working. A restart reuses the same CA and certificate. The log lines tls.ca_created, tls.serving_created and tls.listening carry the CA's SHA-256 fingerprint and dates. Nothing is shown in the console yet, and renewal and person certificates come in later steps (#582, #583).

  • podiumctl and the image are ready for built-in TLS (#580, part of #577). podiumctl start --tls (or podiumctl config set PODIUM_TLS=on) turns TLS on; it is off by default and nothing listens on HTTPS yet, because the server's CA and HTTPS listener come in the next step (#581). With it on, container mode publishes the HTTPS port (4318, or PODIUM_TLS_PORT) next to 4317 and mounts ~/.podium/tls at /data/tls, so the CA will survive a restart, a rebuild and a removed volume and can be backed up. podiumctl warns when that mount is missing, and the server logs tls.storage_not_persistent when /data/tls is not on a named volume or bind mount. podiumctl rm now says the TLS folder is kept and rm --purge-tls --yes deletes it; status shows a tls: line. The image carries openssl and exposes 4317 and 4318. Standalone mode makes data/tls (mode 0700) beside the database.

  • Fold the Plan / Files column on the orchestrator page (#604). A button on the column's edge hides the plan and files so the Agents section takes the full width, and a slim rail with the same button brings them back. The choice is kept in the browser for every orchestrator's page, and the selected tab and scroll position are there when the column opens again. Stacked at a narrow width, the button hides the panel in the stack.
    Standards: new .fold-col, .fold-clip, .fold-tab and .fold-rail (the Plan / Files column's wrapper, the box that clips the hidden panel, its fold button built on .tbar-tab turned 90 degrees, and the rail left while folded), and .two.folded; the plan-panel sample's selector moves to .two > .fold-col > .fold-clip > .sec. Approved by the maintainer when the mockup was accepted on #604.

  • Previous and Next in the file diff modal (#630). The diff window now has the Files tab's pager at its lower left: "Showing file 3 of 12" and Previous and Next buttons that step through every file in the list, in its order, without closing the window. Each button is named for the file it opens, and the first and last file disable the matching button. The title, counts and diff change in place, the scroll returns to the top and the Wrap lines choice stays. Only the file in view is asked for, a moment after the last step so that holding a key down does not queue a request for every file it passes, and a diff that arrives for a file you have already left is dropped. At a narrow width the switch moves up beside the pager.

  • A Wrap lines switch in the file diff modal (#623). Long lines in a file's diff now wrap at the pane edge by default, and a Wrap lines switch at the lower right of the modal turns that off for today's sideways scroll. A wrapped line continues under its own text, with its number and the + or − shown once on the first line. Side by side, the old and new halves of a changed line share one grid row, so they stay level, and keyboard and screen reader order is unchanged. The unified column (below 720 px) wraps the same way. The choice is kept in the browser and a failed read falls back to wrapped. The note moves into a bottom row with the switch, and the row shows only where a diff is drawn. Nothing is sent or stored by the server.
    Standards: the .sw on/off switch is used outside Admin, in the diff modal, so its rules move from admin.css (under .adminv) to fleet.css with no change in Admin; its state word sits beside a label, .sw > b; and the modal gains a bottom row, .mf, holding the note and the switch. Approved with the mockup on #623 (mockup/podium-file-diff-wrap.html).

  • Record a session whole and replay it (#353). The owner of a session, or an Admin, can record the orchestrator and any of its agents and forks from the orchestrator page, and play the run back later: every prompt, what the model wrote, and each tool call with its whole input and whole result. It is off by default and needs three yes answers: the Admin's new Session recording feature flag (it needs a token on the server), the choice of the session, and the reporter's new sessionRecording option ("Allow session recording", off by default). Only the session's owner and an Admin can read, stop, delete or replay a recording. Everyone sees that a session is recorded, by whom and when it goes, and never what is in it, and nobody can hide that. Secrets are replaced on the machine before anything is sent, which is best effort and not a guarantee. A recording is kept 6 hours by default (1 hour to 7 days), stops at 512 MB for one session or when all recordings reach 4 GB, and goes out in 5 second batches. The limits come from a measurement with simulated traffic, not real sessions. Not captured: the model's thinking text (the mod receives it empty, tested on one model at one effort; the replay marks where it thought), and a question or permission prompt answered in the terminal leaves no decision row. This changes the report and adds a route, so the reporter must be updated too; installed copies of the mod update at the next release. docs/REPLAY.md is the new page that explains it end to end.

    • Admin. Settings gains a Session recording section (keep time, disk budget, limit for one session) and a Recordings table with Stop, Open replay and Delete. Stored activity lists recordings with Source set to Recordings. Each action is written to the Admin log, never with any text.
    • The picker. A Recording cell, last in the orchestrator page's facts, opens a dialog to choose the loops and start. It says when the owner has not allowed recording.
    • The notice. A band on the card and a layer on the orchestrator page, with no X and no switch, and a line in the owner's terminal when recording starts, stops or hits a limit. History marks a session whose recording is kept.
    • The replay. A page of its own at #/?open=<id>&replay: loop filter, position slider, Jump to next failure, results under their calls, answers and approvals sent from Podium shown with who sent them, the newest 200 rows with Show older. Stopping keeps what was recorded.
    • Measurement. scripts/podium-recording-load.mjs reproduces the write-load numbers; scripts/podium-recording-seed.mjs seeds a throwaway server with recordings.
      Standards: new .band.recording (the card band) and .rec-bar / .rec-bar.kept (the orchestrator page's layer) are consent notices, not wall notices: no X, no switch, never hidden while a session records, an exception to the wall-notice rules; new .badge.recording; .fact.rec with the .rec-i ring and disc (idle, .live, .kept); Machine and System join into one Name | System cell (.mname, .sep), superseding the separate cells approved on #427, so the grid stays twelve cells; feed rows .feed li.mark and .feed li.cur with .who and .res; .scrub, a labelled range input; the Record picker (.recmb, reusing .opts and .opt); the replay page; and the Admin Session recording controls, Recordings table and Source = Recordings in Stored activity. Approved with the mockup on #353 (mockup/podium-recording.html).
  • A nightly prerelease of development (#528). .github/workflows/nightly.yml runs daily at 06:00 UTC and by hand. It tests and builds development, then publishes the tag nightly-YYYY-MM-DD and a GitHub prerelease with a source tarball of that commit, and removes the earlier nightly so one exists at a time. It publishes nothing when the tip already has a nightly or when a test or the build fails. A nightly is never the latest release, so the in-app update notice ignores it (a test now covers that). The workflow takes effect when it reaches main, because a schedule only runs from the default branch. CONTRIBUTING.md says how to install one.

  • scripts/page-shot.mjs loads a Podium page at a set width in headless Chrome (#475). One command gives a full-page PNG at 400 px (or any width and height), and --eval runs JavaScript in the page first, to open panels or report where elements sit, and prints its JSON result. It does not wait for the network to go idle, so it works against the page's open event stream, which chrome --headless --screenshot does not. It starts Chrome with a throwaway profile and always stops it and deletes the profile, also when the page fails to load. --url is required, so it is never pointed at a running server by default. CLAUDE.md and CONTRIBUTING.md say when to use it. No change to the app.

  • The orchestrator's facts grid shows the machine and its system (#427). Two new cells, Machine and System, sit before Reporter, so the grid is twelve cells in two rows of six (two columns by six rows at 640 px and narrower). Blowouts moves from the start of row two to the end of row one to make room. Machine is the host name the reporter sent. System is its OS and version, such as Fedora Linux 44 or macOS 15.4, with the same icon and wording as My reporters and Admin > Reporters, cut with an ellipsis when long. Each has a popup like Resume's and Fork's, opened by hover and by keyboard focus. A session with no machine (no userName) reads "Not reported" in both cells and its popup says to set userName; a machine with no OS reads "Not reported" and its popup says updating podium-reporter fills it in.
    Standards: the facts grid is six columns (facts six), a cell with a popup is fact pop (Resume and Fork share it with the new cells, and the popup rules are keyed to .fact.pop > .tip so a Tip inside a cell is not styled as a popup), and the system's icon and text are sysi and sys-t; approved with the mockup on #427 (mockup/podium-facts-system.html), option C with Machine before System.

  • An orchestrator's page has its own address (#546). Opening an orchestrator changes the address to #/?open=<id>, the same pattern History uses, so the browser's Back button returns to the list of orchestrators, Forward opens it again, and a reload or a shared link opens the same page. Moving between orchestrators in the rail replaces the entry, so Back still goes to the wall. An address naming a session that is not in the fleet lands on the wall with a message.

  • Admin > Reporting (#484, #482, #483). A new menu group with six pages: Usage, Spend, People and groups, Sessions, Failures and Admin activity (the Data kept page moved to Admin > Server > Data, #432). Each takes a period, a day, week or month grouping and filters, states the time zone and how far back its data goes, and exports exactly what it shows as CSV or JSON (and prints). Podium now keeps daily summaries of usage, sessions, failures and the fleet for 395 days (RETAIN_ROLLUP_MS), written before the detailed rows are swept, so a report can reach past the 30 days of sessions and events. The summaries hold counts, tokens, cost and names, and no session content. Admin > Server > Data lists what is stored and for how long.

    • Spend breakdown (#482). A person's row in Spend today opens what the figure is made of: by session, by model, and orchestrator against agents. Each cut adds up to the row, and a model without a price shows a dash.
    • Spend projection (#483). A straight run rate over the last 7, 14 or 30 days projected to the end of the month or 30 days ahead, with the window and assumptions shown, and "Not enough history" when there are fewer than 7 days.
    • scripts/podium-seed-history.ts fills a throwaway database with past days so the simulator can show the reports and the projection.
      Standards: the Admin menu gains a Reporting group and the Server page "Reporting and security" is renamed Security, and new classes exp, note-line, partial, chart, rowbtn, klink, hlinks and cuts come with the page, approved with the mockup on #484 (mockup/podium-admin-reporting.html).
  • The Admin can keep the agent Activity feed, and manage what is stored (#432). Under Admin > Server > Data a new Activity history setting chooses how long an Activity window's entries are kept: Not stored (the default, exactly as before), purge after a number of hours, days, weeks or months, purge when the session ends, or keep everything, with an optional size cap that removes the oldest first. A keep policy needs a token on the server. Entries go to a second SQLite file, activity.sqlite, beside the database; while a keep policy is on, every running loop of a session whose reporter has the activityFeed option on is captured whether or not anyone watches (the mod is unchanged). A purge runs every 5 minutes and writes one Admin log entry per run that removed something.

    • Stored activity (new Admin page). Totals (entries, size on disk, sessions), filters (session, loop, project, owner, age, a range of dates) and a table of sessions. Delete the ticked sessions, everything a filter matches, or everything; each asks first and says how many entries and how much space will go, then compacts the file so the space comes back. Each delete is written to the Admin log with counts and sizes, never text.
    • The Activity window shows stored history, including for an ended session: the newest stored entries with the live ones after them, Show older to page back, and footers that say how many entries are stored, since when, and how long they stay. Only the session's owner and the Admin can read stored text (GET /api/fleet/respond/sessions/:id/activity-history); everyone else sees who can. Under the default policy the window is unchanged.
    • Saving a policy that removes stored entries or stops keeping history asks first and says how much will go.
    • scripts/podium-seed-activity.ts fills a throwaway activity file for load tests.
    • It is not a complete record: the mod keeps 20 entries per loop and one report carries 60,000 characters, so a busy session can lose entries.
      Standards: new .result and .formacts (a button row inside the existing confirmation box) classes, the .srow select width and number-plus-unit row, a native button reset for .klink and .ptab td.who .hint, two new Admin pages (DataPage, StoredActivityTab) and the menu change (Data moves from Reporting to Server and Stored activity joins it, so Reporting has six pages), the OlderRow "Show older" row in the Activity window's status footer (.feedfoot.stat.older), a wider time column when an entry is an hour or more into its loop (.feed.hours), and the stored-history states of the window (the footer count, the note under it, and the empty text for a viewer who may not read it), and the sign-in dialog's wording when stored history is its only reason, approved with the mockup on #432 (mockup/podium-activity-history.html).
  • A Discord server for Podium (#457). The README has a Community section with the invite link, and docs/COMMUNITY.md says how to join, what belongs in Discord and what does not (security problems go to a private GitHub Security Advisory), how the maintainer sets the server up securely and renews the invite, which expires until Discord offers a permanent one. SECURITY.md and the Code of conduct now name the Discord server.

  • The wall tells you when a newer Podium release is out (#499). Once a day the server asks GitHub for the latest release, and when it is newer than the running server the wall shows a notice at the bottom: Podium 0.15.0 is available, in a green pill, with the version running and a link to the release notes. The X hides that release for everyone, and a still newer release shows the notice again. A check that fails (offline, rate limited, no answer) shows nothing. Admin > Settings > Feature flags has a Release check switch, on by default; off, the server makes no request to GitHub. Pre-releases and drafts are ignored.

  • podiumctl runs the container with Docker as well as Podman. Container mode uses Podman when it is installed and Docker otherwise, for building, running and every other container command, and it is the default mode when either is installed. --engine podman|docker chooses, and the choice is saved as PODIUMCTL_ENGINE so stop, status and the boot unit keep using the engine that started the container. status shows the engine. Under Docker the container can reach this machine as host.docker.internal, and a new .dockerignore keeps a Docker build to the same files as a Podman one.

  • Pin cards on the Fleet wall (#496). A pin button in each card's first row, just before the model, holds that card's place on the wall while statuses, costs, the sort order and new sessions change around it, until it is unpinned. Several cards can be pinned. Pins are kept in this browser, hold their slot inside a band under Group by, and are dropped when their session ends.
    Standards: new .pin button and .pinslot space on the session card, approved with the mockup on #496 (mockup/podium-pin-cards.html).

  • Kanban page: collapse the KPI rows (#538). The eight KPI tiles fold into one thin bar and open again from a chevron control, so the cards get the space back. The choice is remembered on its own, apart from the header's and the Fleet page's.

    • Standards: a new .kpi-tab control (a pill on the KPI block's edge when open, a full-width bar when collapsed), built on .tbar-tab. Approved by the maintainer on #538, with the mockup mockup/podium-board-kpi-collapse.html.
  • An activity entry can carry a Bash call's whole command and its output (#431, protocol). An entry gains optional id, command, commandCut, output, outputCut, done, exitCode and failed fields, the session gains activityOutput, and the report reply gains watchOutput, the loops a viewer has the feed open on. The command rides the activityFeed option (up to 20,000 characters, redacted, cut with a visible marker); the output needs the new activityOutput option and the Admin's new activity-output flag, both off by default, is captured only while a viewer has the feed open, and is never stored. Commands and output have their own 200,000-character budget per report, apart from the 60,000 characters of summaries. An older mod's entries validate and look as before. The server, mod and console changes follow in the same issue.

  • The reporter mod sends a Bash call's whole command, and with a new option its output (#431, mod). While activityFeed is on, a Bash entry carries its call id and its whole command beside the 300-character summary: redacted by the same rule a recording uses, line breaks kept, ANSI codes and other control characters removed, cut at 20,000 characters with a visible [cut off at 20,000 characters] marker. The new activityOutput option, "Send a Bash command's output" (off by default, and needing activityFeed), adds the call's output (stdout then stderr, cut the same way) and done, failed and, only when Claude Code's text for a failed call names one, exitCode, to the same entry once the call returns. It is read only for a loop the reply's watchOutput lists, so an older server gets none, and a loop that leaves the list forgets what it held. A report holds at most 200,000 characters of commands and output and at most 480,000 bytes in all, dropping output first and then commands. The report always says whether activityOutput is on.

  • A finished Bash call says so even when its output was not collected (#598, protocol, mod, server). While activityFeed is on, the reporter mod sends done, failed and exitCode on a Bash entry whenever the call returns, with or without output; output still needs the activityOutput option, the activity-output flag and a viewer on the loop, and none of it is kept or sent otherwise. The server holds and serves the three fields with the entry under activity-feed alone and keeps them when the output goes, and the Activity history still stores only the command. An older mod sends done only with output, and an older server drops the new use harmlessly.

  • A Bash row says whether its call is running, finished or never reported (#598, console). A finished call with no output shows its status mark (a bare check, or a cross with "Exit N, failed" or "Failed") on the line and beside Output, with "Output was not collected." (or the Admin's or the machine's reason, which stay first). The newest call in a loop that is working, with no finish report, says "Running." and "running" on the line. Any other call with no finish report, including an older reporter's rows and every call of a loop that has ended, says it was not collected and shows no mark. The replay is unchanged.

  • A Bash row in the Activity feed opens to the whole command and its output (#431, console). In the agent panel, its Expand modal, the orchestrator's Respond modal and the session replay, a Bash row is closed by default and opens on a click anywhere on it (the line stays a real button for the keyboard). Opened, it shows the command, then the output with a status (a bare check for success, a cross with "Exit N, failed" or just "Failed" when no code is known), each with a Copy button, and a Collapse button. Which rows are open survives the refresh, and opening one does not move the list away from where you are reading. A row with no output says why in words: the Admin has it off, the machine has it off, it is not kept in history, or it was not collected because output is only collected while the panel is open. Admin > Settings gets a warning under the new flag while it is on, and Admin > Server > Data says the history keeps the whole command and never output.

  • Standards: new Activity feed row pieces for an openable Bash row (#431, H3 of #530): .feed li .tgl (the whole row is the toggle), .feed li .xp with its .xh header (the opened command and output panel, wrapped, no sideways scroll), .exit (the exit status: a bare check for exit 0, a cross and the words "Exit N, failed" for a non-zero exit, so the status is a shape, a word and a color), a bare-check success status, a Copy button as a small button inside .xh, and a cross for a failed exit. Two wordings are part of it: "Output was not collected." with the second line "Output is only collected while this panel is open." for a Bash row that finished before the panel opened or whose state is not known, and a cross with the single word "Failed" (no code) for a failed call whose exit code is unknown. Approved by the maintainer with the mockup on #431 (mockup/podium-feed-expand.html).

  • Standards: the status mark of an openable Bash row now shows on a row whose output was not collected, on the line and beside Output, and a call still running reads "Running." with the word "running" on the line (#598). Approved by the maintainer with the mockup on #598 (mockup/podium-feed-expand.html).

  • Standards: a new Admin feature-flag row, Bash output in the activity feed (activity-output, off by default, under Agent activity feed) with a warning bar, and two wordings for a row that has no output, "Output is turned off by the Admin." and "Output is turned off for this machine." (naming the activityOutput option, "Send a Bash command's output"). They use the existing .srow, .sw, .warnbar and .feedoff. Approved by the maintainer with the mockup on #431 (mockup/podium-feed-expand-admin.html).

Changed

  • The design standards match the app again (#664). docs/standards/ is regenerated for everything added since 1.2.2: the Certificates, Data, Stored activity and Reporting pages, session recording and its replay, the Plan / Files fold, the certificate pill, the update notice, pinned cards and the opened Bash row. Every component file has a section and every class in the stylesheets is rendered by a sample or listed in not-rendered.json with its reason.

  • Reporters connect on their own mutual-TLS port, the console gets no HTTPS, and an expired certificate is not renewed (#665, part of #577). With TLS on, port 4317 stays plain HTTP for the console, Admin and reporters that use a token, and a second port (4318, PODIUM_TLS_PORT) is HTTPS with requestCert: true and rejectUnauthorized: true. It serves only the routes a reporter calls and POST /api/reporter/renew; a connection with no certificate, an expired one or one from an untrusted CA is dropped in the handshake, and any other path is a 404. Nothing in Podium sets rejectUnauthorized: false now, which is what CodeQL's js/disabling-certificate-validation flagged in the review of #663. The 30-day grace is gone: tls.reporter.graceDays, the expired-certificate branch of the renewal check and its forgery checks, the PAST_GRACE refusal and the graceDays field of GET /api/admin/people are removed (a stored tls.reporter.graceDays row is ignored, and a request to set it is a 400), and an expired certificate can no longer skip the hourly renewal limit. The reporter helper compares the certificate's end date with its clock before it connects and sends nothing; the status line and /podium say "Your Podium certificate expired on 2026-08-30. Ask an Admin to generate a new reporter config." (CERT_EXPIRED, replacing CERT_PAST_GRACE and "renewing certificate"). podiumctl and docs/SERVER.md call 4318 the reporter port, and the walk-through no longer tells a person to trust the CA in a browser. SPEC 5.9, 6, 7.1 and 7.7, DECISIONS 42, SERVER, REPORTER, ARCHITECTURE and SECURITY are updated. The Admin pages follow it: Download CA, the browser-trust text and the grace setting are gone, and Admin > People shows a lapses soon badge before a certificate ends.

  • The Certificates page says what a reporter that misses the overlap needs (#637, part of #577). While a CA rotation is open, the blue band now says that a reporter that does not connect before the cross certificate ends, or whose own certificate expires first, is refused and its person needs a reissued reporter config, beside the date the cross certificate ends. The Rotate CA… confirmation says the same before the Admin starts, with the number of days and the date the overlap ends, and gives a laptop asleep that long as the example. Wording only, in the existing band and confirmation; the cross certificate's lifetime is unchanged. Mockup: mockup/podium-tls-certificates.html (#573), state "CA rotating".

  • One function signs a person's certificate (#613, part of #577). Making a certificate from a key openssl generates (an Admin issuing a config) and from a public key a reporter sends (a renewal) had two copies of the work folder, extension file, serial, signing and date readback. signClientCert now does those once and the two callers supply only how the key reaches openssl x509. No change in behavior; a test now checks both produce the same profile and clean up.

  • The left and right panes' collapse buttons are now the pill the top bar, the Kanban KPI rows and the Plan / Files fold use: the same size, hover, focus ring and styled tip, with the shortcut in the tip ("Collapse the left pane ([)") (#626). It sits on the pane's inner edge, near the top, and in the 24 px strip a collapsed pane leaves. Where the panes stack, at 1040 px and below, it is a full-width 28 px bar. All five collapse controls are one component, EdgeTab, and one set of classes; PaneTab and .pane-tab are gone.
    Standards: new .edge-tab, the one collapse and open pill (32 x 16 on a horizontal edge, 16 x 32 on a vertical one, a tip from data-tip, a Sonnet focus ring), placed by .edge-tab-top, .edge-tab-kpi, .edge-tab-fold, .edge-tab-left and .edge-tab-right, and .edge-tab-shut while what it controls is hidden. It replaces .pane-tab, .tbar-tab, .kpi-tab and .fold-tab, and every later collapse or open control uses it. Approved by the maintainer on #626, with the mockup mockup/podium-pane-collapse-control.html, option A.

  • Logs: a refused credential (auth.failed, the 429 throttle included) and the tls.* events now print in the default text output at warn, where they were hidden unless PODIUM_LOG_LEVEL=debug. A reporter that retries with the same bad credential gets one line a minute for the same route, source and reason, with a count of the repeats held back. The json format and shipped logs still carry every line, and no token, certificate or key is printed (#602).

  • The simulator (scripts/podium-sim.ts) sends an OS with each machine of a --person: macOS, Fedora Linux or Windows on the laptop, none for some (an older reporter), and Ubuntu on the build box, so the Machine and System cells show every state (#427).

  • Admin > Reporting: the Admin activity report says when the log held more than 1,000 entries in the period and only the newest are shown, and its reply carries truncated (#529).

  • Reporting reads less: a report for today no longer scans the agents, reporter links and failures of every stored session, and the Spend breakdown reads its sessions and agents in two queries instead of several per session (#529).

  • Admin: Data kept is now Data, in the Server group beside Settings, Model prices and Security, because it holds a setting and deletes; Reporting has six pages (#432).

  • Admin: Spend moved from Activity into the new Reporting group, and the Server page "Reporting and security" is now called Security so there is one "Reporting" (#484).

  • Standards: a green variant of the pill, pill good, joins the default, warn and bad. It is used first by the update notice. Approved by the maintainer on #499, with the mockups mockup/podium-update-notice.html and mockup/podium-update-check-admin.html.

  • Added SonarCloud quality and CodeQL status badges to the README (#517).

Fixed

  • A certificate whose person is revoked is refused, and a certificate reissue brings the person back (#659, part of #577). certAuth and the renewal check now also refuse a live certificate whose person has revokedAt set, with the same revoked answer as a revoked certificate, so a path that revokes a token without its certificates cannot leave the reporter in. POST /api/admin/people/:id/certificate/reissue clears revokedAt (token unchanged) and logs a person_restored entry, so the new certificate works.

  • Revoke certificate no longer says the reporter is refused at once (#658, part of #577). The Revoke certificate dialog and the Reporting people help now say it stops the reporter that uses the certificate, that the token still works until it is revoked, and that Revoke token or Revoke all cuts the person off, which is what requireToken does. docs/SERVER.md, the User guide and .github/SECURITY.md say the same, and SECURITY.md notes that the Podium CA has no name constraints.

  • The CA overlap ends when the cross certificate expires, as the spec says (#657, part of #577). TrustStore.caCerts() and trustedIssuers() kept the previous CA trusted for client certificates until an Admin retired it, even after cross.crt had expired, so a long-lived certificate from the old CA could outlast the 120-day overlap. They now drop the old CA after the cross certificate's notAfter; retiring it still ends the overlap sooner. The Certificates page's rotation band says the old CA is accepted until that date or until it is retired. This was never a way in: a certificate is accepted only if its fingerprint is a stored row, and nothing new can be signed with the old key.

  • The reporter says the clock may be wrong when a good server certificate looks expired or not yet valid (#650, part of #577). A machine with a wrong clock saw server certificate expired and blamed the server. Now, on CERT_HAS_EXPIRED or CERT_NOT_YET_VALID, the status line says check the clock and gives this machine's date, in two cases: the server's time is unknown (the handshake failed before any reply), or the Date header of the last reply from the server differs from the machine's by more than 5 minutes, and then both times are shown. It never says the clock is certainly wrong. A server time within 5 minutes of the machine's keeps the old message. docs/REPORTER.md has the new row, docs/SPEC.md 7.7 words the rule, and docs/SPEC.md 7.7 and docs/SERVER.md now name PODIUM_TLS_PUBLIC_PORT (#649).

  • A reporter config names the HTTPS port people reach the server on, not the container's (#649, part of #577). In container mode podiumctl always runs the server on 4318 inside and maps the host's PODIUM_TLS_PORT to it, so with PODIUM_TLS_PORT=5318 every new config said https://<host>:4318, which nothing listens on. podiumctl now also sets PODIUM_TLS_PUBLIC_PORT to the host port in the container, and the server writes it into a person's config, in the Admin's Create user and in a reporter's renewal. Standalone is unchanged. Configs already issued with the wrong port need reissuing.

  • The reporter says where a name that does not match its config file came from (#647, part of #577). A session with no name already took the config file's name; "wrong config for this name" appears only when the session holds another one. The message now says whether that name is from the userName option, PODIUM_USER or the identity the session already holds, and to empty it so the session takes the file's name. docs/SERVER.md and docs/REPORTER.md no longer tell the person to set the name.

  • A revoked certificate shows certificate refused, not token refused (#646, part of #577). When a reporter that uses a config file gets a 401, the status line, /podium and the toast now say the server turned the certificate away and to ask an Admin to reissue the reporter config; token refused stays for a session with no config file. docs/REPORTER.md's status table drops the token refused row for a revoked certificate and says what certificate refused covers.

  • The reporter no longer falls back to plain HTTP when PODIUM_REPORTER_CONFIG names a file it cannot use (#645, part of #577). A missing file, or one that is not a reporter config, now shows config unreadable in the status line and in /podium with the path, and the session sends nothing, over HTTPS or HTTP, instead of reporting over the plain address and showing token refused. With the variable unset and no file in ~/.podium/, nothing changes.

  • Admin > People: three follow-ups from the T9 review (#629, part of #577). Download certificate now follows the base Admin is mounted under, so it no longer 404s behind a proxy path. The Actions menu moves focus without scrolling, so opening it cannot scroll the page and close it at once. The Issue new token confirmation says, with TLS on and the person's certificate revoked, that their reporter stays refused until Reissue config; the wording was approved on #629.

  • Sessions renewing together can no longer both get the renewal lock (#611, #616, part of #577). A reporter's renewal lock is now a numbered file, <config>.lock.<n>, made with O_EXCL and naming its owner. It is held by the highest number and free when there is none or the highest is older than a minute (its owner died, or let go: a finished session ages its file instead of removing it, so a number is never used twice). To take it a session creates the next number, and of several that want the same number exactly one succeeds, so taking over a stale lock removes nothing that two sessions could race on, one can never delete a lock another has just made, and one working from an old reading cannot be handed a number that was held and released since. The older files are removed, leaving the newest. A session that is still running refreshes its file every 20 seconds, so a live renewal is never taken over however long it takes, and one that finishes ages only its own file. This replaces the earlier, unreleased fix for #611, which took a second lock to clear a stale one and could itself race when a process died holding that second lock. The numbered lock also takes the plain <config>.lock of the first renewal code while it works (#621), and a marker, <config>.lock.scheme, records the lock scheme (2) so a helper that finds a higher number refuses to renew with a message instead of ignoring a lock it does not know. So a session on an older helper and one on this helper cannot renew at once either: the older one waits for this one's plain lock, and this one waits for a live plain lock the older one holds. The one race left is with an older helper clearing the same stale plain lock as this one, which only the older code can close.

  • npm run dev loaded a blank page (#551). The frontend imports protocol.ts, which imported build.ts for parseBuild, and build.ts reads node:fs; the Vite dev server does not tree-shake that away as the production build does. parseBuild now lives in parseBuild.ts with no Node imports (build.ts re-exports it), and a test fails if protocol.ts reaches a node: module again.

  • The Linux start-on-boot notes now say what was tested (#268). On Bazzite 44, standalone mode started at boot with loginctl enable-linger and at login without it, across two reboots. Container mode has not been rebooted. Documentation only; no change to the app.

  • The Admin > Reporting charts drew every bar as a 5 px dash, because the bars shared the class bar with the context meter and its CSS height won over the bar's height. The bars now use their own class (rbar) and rise from the baseline to their value, and the chart labels its gridlines (the top value, half of it and zero) and the first and last period (#532).

  • The first retention run after an upgrade now writes the day the session cutoff falls in, so the oldest reporting day is no longer missing; it can be partly built (#529).

  • The Markdown linearity test is less sensitive to a contended CI runner: it takes the best of five measurements, allows a ratio below 10, remeasures a case once only if the first ratio exceeds the limit, and cleans up each render outside the timed interval so large DOM trees do not accumulate. A quadratic renderer still measures near 16 and fails (#514).

  • The three SonarCloud issues that held Security and Reliability at C on main are fixed in code (#515). The machine id no longer falls back to Math.random() when the sandbox has no crypto; /podium name uses a pattern whose parts cannot overlap; and the wall button's fullscreen request no longer sits in a try that Sonar flagged. The id is still 12 hex characters, and a refused or missing fullscreen still leaves wall mode on and fullscreen off.

  • podiumctl prints a secret through its own helper, and its health check only calls the machine's own server (#676). podiumctl token and config --show-token write the secret they were asked for with process.stdout.write, so no other message can route one into console.log (js/clear-text-logging). The health check calls 127.0.0.1 for a wildcard bind (0.0.0.0, :: or empty, which used to produce an unusable http://:: address) and brackets an IPv6 bind. It refuses a bind that is neither an IP address nor a plain host name (js/file-access-to-http). Output is unchanged.

  • Open CodeQL alerts are resolved in code (#676). A tool name from a hook payload and a model id from the Admin are held in a Map, so none can reach an inherited property (js/remote-property-injection). The route that serves index.html is limited to 600 page fetches a minute per address (js/missing-rate-limiting). The inline-script finder ends a script at </script > as well as </script>, so a page that uses one still gets the right CSP hashes (js/bad-tag-filter). The two typescript:S6845 warnings on the activity feed and the Facts bar are annotated.