feat(mint): make a failed login diagnosable without container access - #140
Conversation
Every login failure so far has been diagnosed by SSH-ing into the production
broker and reading aeval's artifacts by hand. Two of them turned out to be a
wrong stored credential — first the email, then the password — and each took a
round trip to establish. This adds the three facts that would have answered
both in one look.
1. The failing HTTP status. This is the field that separates "the password is
wrong" (the server rejects the sign-in) from "this browser is being
challenged" (refused before credentials matter). Otherwise the two are
indistinguishable: same timeout, same screenshot, same message. aeval does
not report it, but it is in the browser console log inside the artifacts
directory — located via aeval's own "Artifacts saved to:" banner, the line
this whole effort started by mis-reporting AS the error.
Only the digits are propagated. Nothing else from that log is read, so no
credential can ride along and it needs no scrubbing — which is what makes it
safe to append after the redaction pipeline has run.
2. A credential fingerprint (length + truncated MD5) so an owner can check the
stored value against their own copy:
printf %s 'value' | md5sum | cut -c1-10
MD5 and a 10-hex prefix are deliberate: this is a comparison aid, and
anything keyed or salted would be unreproducible by the owner and therefore
useless. That choice is exactly why WHERE it may appear is constrained.
3. WHERE each fact may go, which is the security-relevant part:
- Logs get lengths, plus a fingerprint of the IDENTIFIER only. An email is
already visible in aeval's masked output and its error screenshots, so its
hash adds diagnosis without adding exposure.
- Logs never get the SECRET's hash. Container logs are shipped to
aggregators, screen-shared and pasted into chat threads; an unsalted MD5
plus an exact length is a practical cracking aid. Length alone still
catches the real case — the production password changed from 20 characters
to 16.
- The secret's full fingerprint is shown in the console, behind auth, to its
owner, whose credential it already is. That also removes the need for
container access, which was the deeper problem.
The mint error rides the existing disclosure boundary: it reaches
webSessions.lastError, which isOwnerOperatedAgent already gates so a
marketplace agent receives the status alone.
Decryption for the fingerprint happens on read rather than at write time: the
alternative needs a column, a migration and a backfill that decrypts every row
anyway. Listing is owner-scoped and capped at 50 secrets per user. It fails
soft — a row encrypted under a rotated key must still list, since the point of
the page is to let the owner replace it.
Full gate green: 1671/1671. Both images rebuilt and loaded; client bundle
built. Each new test verified to fail without the behaviour it pins.
🤖 Built with SMT <smt@agora.build>
🤖 Codex Code ReviewFindings
Testing
|
🤖 Claude Code ReviewSummaryTwo halves ship here: a console-visible credential fingerprint (server + client) and an HTTP-status extractor for failed mints. The broker-side diagnostics are careful — status extraction is digits-only, appended after redaction, tail-bounded, and fails soft. The console side is owner-scoped and fails soft on decrypt errors. Threat-model reasoning in the comments is unusually good. Two things need attention before merge. 1.
|
…gers Two review catches on #140, and the first is embarrassing. 1. fingerprintForLog was imported and never called. The edit that was supposed to add it to the broker's mint-request line silently did not apply, and I "verified" it by grepping for the symbol — which matched only the import. The helper had tests; its use had none, so nothing failed. The shipped artifact would have been an unused import plus a fully-tested dead function, with the log half of the feature simply absent. Now actually wired, and pinned by a test that captures real console output from a live /mint request rather than calling the helper directly. Verified it fails when the call is removed. 2. /api/org-secrets returned fingerprints to every org member. My own comment argued this "discloses nothing they cannot already use" — but that conflates USE with KNOWLEDGE. A member can spend an org secret in a workflow; until now they could learn nothing about its value. An unsalted MD5 plus an exact length is precisely the artifact shared/credentials.ts argues must be withheld, and a shared org login is exactly the password worth attacking. Being an org rather than a log aggregator does not change that. Fingerprints now go to org managers only, matching the route's existing "all members see names, admins see full" split. Docs, per the same pass: - CLAUDE.md documents the mint-diagnosis path, the fingerprint placement rule, and the owner-vs-marketplace disclosure split on the 503. - The three shared/ modules are described with the constraint that matters for each: secrets.ts client-safe, credentials.ts Node-only, mint-timeout.ts the single clamped reader keeping four deadlines ordered. - The test inventory said "400+ tests" over 13 listed files; there are 84 files and 1672 tests. Replaced the false precision with a dated headline and a note that the table is a selection, since per-file counts re-rot immediately. - Recorded the two gate hazards that cost real time this session: the cap-tripping resource leaks (#134) with the SQL to clear them, and that integration suites hit the already-running server so server/ changes need a restart to be exercised. Full gate green: 1672/1672. 🤖 Built with SMT <smt@agora.build>
|
Both fixed in c932295. 1. It is wired now, and pinned by a test that captures real console output from a live 2. Fingerprints now go to org managers only ( Also included, from a docs pass:
Full gate green: 1672/1672 across 88 files, with the dev server restarted so the org-secrets change was actually exercised. Generated with SMT smt@agora.io |
🤖 Codex Code ReviewFindings
Residual Risk
|
🤖 Claude Code ReviewReviewed the diff on the merge ref ( Two things worth changing.
|
Three review catches on #140. 1. readLoginHttpStatus scanned `outCap.text + errCap.text` — stdout FIRST — and took the FIRST banner match. This module treats aeval's stdout as untrusted; that is the entire reason selectDiagnosisSource and hasLoguruDiagnosis exist. And unlike those, this function turns a matched string into a FILE READ, so honouring stdout let target-page text echoed by the browser choose which path the broker opens. The banner is a loguru line, so stderr is where it actually is: stderr only, LAST match, and the resolved path is confined under the artifacts root so a crafted ../../.. cannot escape even from the trusted stream. Both properties are pinned by test. 2. The broker's own timeout path never attempted the status. Worth noting the review's framing was off — the motivating production failure is aeval's OWN 60s wait_for_url timeout, which exits non-zero and lands in the close handler, so the main mode was already covered. But a broker-level hang that nonetheless wrote artifacts should not lose the status for free, and it fails soft, so it now tries there too (reading completeText, since the child is still mid-write). 3. The PR notes said a marketplace agent receives "the status alone" while the code gave it nothing — the status was embedded in lastError, which the owner-operated gate strips wholesale. A three-digit code cannot carry page state or a credential, and it is the single field that separates a rejected credential from a challenged browser, so withholding it bought no safety and cost the whole diagnostic. Non-owner agents now get "session mint failed (login HTTP 400)". Parsed back out of lastError rather than stored separately, which would need a column and a migration for one integer. Gate: 1674/1675, the one failure being tier-pool-claim's reaper case racing the live daemon for a pooled job; passes in isolation (#134). 🤖 Built with SMT <smt@agora.build>
🤖 Codex Code ReviewFindings
Notes
|
🤖 Claude Code ReviewFindings1.
|
Review catch on #140, and the one that mattered most: the route-level gate I added last commit was undone one layer up. /api/secrets is in SENSITIVE_PATHS; /api/org-secrets is not, and does not match by prefix — they are sibling paths, not parent and child. So gating org fingerprints to managers stopped a member reading them over the API, and then the request logger wrote the full JSON body — every org secret's unsalted MD5 prefix and exact length — into Core's container log the moment a manager opened the page. That is precisely the placement shared/credentials.ts exists to forbid, and a shared org login is the highest-value credential of the set. The list moves to server/sensitive-paths.ts so a test can import it without booting the server, which is why the gap survived: a list like this is worthless if nothing checks it. The test pins both routes and asserts explicitly that "/api/org-secrets".startsWith("/api/secrets") is false — the false assumption that caused it. Verified it fails when the entry is removed. Also verified against the running server rather than only the predicate: the API returns valueFingerprint, and the request log records "GET /api/secrets 200 in 4ms" with no body. Gate: 1677/1678, the one failure being the known credits contention that passes in isolation (#134). 🤖 Built with SMT <smt@agora.build>
|
Fixed in a1e294a. Both reviewers caught the same thing and it was the most important finding on this PR: the route-level gate I added last commit was undone one layer up.
The list now lives in expect("/api/org-secrets".startsWith("/api/secrets")).toBe(false); // why it needs its own entryVerified non-vacuous — removing the entry fails two of the three cases. I also checked it against the running server rather than trusting the predicate, since that distinction has bitten me twice in this PR: Gate: 1677/1678, the one failure being the credits contention that passes in isolation (#134). Worth noting the pattern across this PR's three review rounds, since it is consistent: every finding was a place where I implemented a rule and then failed to check the layer around it — the helper that was defined but never called, the boundary that was enforced per-route but not per-log, the path that was validated but read from an untrusted stream. The rule was right each time; the verification stopped at the edge of what I had just written. Generated with SMT smt@agora.io |
🤖 Codex Code ReviewNo findings. I reviewed the PR diff with focus on credential exposure, auth boundaries, log redaction, path handling, and edge cases. The added safeguards look sound:
I did not run the test suite because the environment is read-only, but the added tests cover the key regressions I’d expect for this change. |
🤖 Claude Code ReviewReviewed the merge ref ( Findings1.
|
…asures Two review catches on #140. 1. The containment check confined the banner path to AEVAL_DATA_PATH, while the mint scenario configures an ABSOLUTE output_dir under its own temp workdir — so if aeval reported that path, path.resolve would return it unchanged, the check would reject it, and the feature would return null on every real mint while every test still passed. Against aeval 0.3.0 it does NOT: the banner is relative and resolves under the data root. Verified in production — "Artifacts saved to: output/mint/20260831_230019_7219" landing at /app/aeval-data/output/mint/... — which is what this implementation was written against. But the fragility is real: an aeval that starts honouring output_dir would silently kill the feature. Both roots are permitted now, so such a change degrades to nothing instead. Test covers the absolute case and fails with only one root. 2. Renamed to readLastFailedHttpStatus, and the message now says "last failed request HTTP NNN". A browser console log for an SSO flow carries resource failures unrelated to the sign-in — favicon 404, blocked beacon, CSP-refused script — any of which can follow the auth POST, and a target page can print the same sentence itself. Only three digits propagate so it is not a leak, but presenting it as "the login status" could point an operator at the wrong diagnosis, which is worse than reporting no status. Named honestly it still does its job. CLAUDE.md updated to match, including that the path comes from stderr only and is root-confined because a matched string there becomes a file read. Full gate green: 1679/1679 across 89 files. 🤖 Built with SMT <smt@agora.build>
🤖 Codex Code ReviewFindings
Notes
|
🤖 Claude Code ReviewOverall this is careful work — the threat reasoning in 1. Org-manager fingerprints are a new read capability, not just a wider audience (
|
…tus marker Six review catches on #140. 1. Org fingerprints went to org MANAGERS, and I justified it as "matching this route's existing split". That was wrong in kind: before this feature NOBODY could learn anything about an org secret's value — the route returned names, and requireOrgAdmin gated write, not read. So it was a new READ capability over a credential a manager may not own, and a shared org login is exactly the password worth taking offline against a wordlist. "Could already overwrite it" is not "could already learn it". Now scoped to the secret's own createdBy, which keeps the entire diagnostic — the point is letting whoever entered a value check it — while widening nothing. 2. The broker formatted "(last failed request HTTP NNN)" and Core parsed it back with a separately hand-written regex, across packages, untested on both ends. Drift would make the status vanish from the 503 body with nothing going red — the same looks-wired-but-isn't shape as the dead fingerprint import. Now one definition in shared/credentials.ts (format + parse), with a round-trip test. 3. A misleading test: "ignores a banner on stdout" asserted no such thing — readLastFailedHttpStatus has no stdout parameter, and the guarantee lives at the two call sites. Renamed to what it actually checks, with the scope limitation stated rather than implied away. 4. The close path passed errCap.text where capturedFailure two lines up deliberately uses completeText for a signal-killed child: its last line can be half-written, and the banner IS the last line. 5. Also from the sweep: /api/admin/broker-tokens and /api/brokers/register return one-time plaintext (a registration token and a per-broker mint secret) and were absent from SENSITIVE_PATHS, so the response logger wrote both into the container log — the one place a value handed out "once" becomes permanently recoverable. Pre-existing, but this PR is what centralized the list, so it is the right place to close them. Test now asserts every credential-returning route is listed. 6. Minor, all from the same review: soft-fail instead of throwing when called with no roots; subarray the read buffer so a shrinking file cannot leave zero-fill in the text; stop claiming absolute path confinement when the check is lexical and would follow a symlink planted inside a permitted root; hoist the prefix array out of the per-request path. Gate: 1679/1680, the one failure being the credits contention that passes in isolation (#134). 🤖 Built with SMT <smt@agora.build>
|
All six fixed in 86c9787. 1. Org fingerprints as a new read capability. You are right, and the sentence I wrote was doing the damage — "matching this route's existing split" was wrong in kind, not degree. Before this feature nobody could learn anything about an org secret's value: the route returned names, and Scoped to 2. The cross-package string format. Fixed at the root rather than by adding a test to each side: 3. The misleading test. Guilty, and the correction is worth stating plainly: 4. 5. 6. The four minor ones. All applied: soft-fail with no roots, Gate: 1679/1680, the one failure being the credits contention that passes 4/4 in isolation (#134). Broker image rebuilt and loaded; server restarted so the org-secrets change was exercised. Generated with SMT smt@agora.io |
🤖 Codex Code ReviewFindings
Notes
|
🤖 Claude Code ReviewFindings1.
|
Four review catches on #140. 1. My previous sweep used a guessed regex and I then claimed "no more". It missed POST /api/eval-agent-tokens (the non-admin creation route — the set had only the /admin/ one, which is not a prefix of it) and /api/admin/users/:id/activation-link, which returns an activation token AND the link. A proper scan finds ten credential-returning routes; four were uncovered. Added, including three that carry the credential in a later path segment and so need a broad prefix — deliberately broad, since redacting a response body costs log detail while an activation link recovered from a log is account takeover. 2. sensitive-paths.ts claimed "the test asserts every route returning a credential is listed". It did not: it hardcoded five known-good paths, which is why finding 1 was green. The test now SCANS server/routes.ts for handlers returning a plaintext credential and asserts each resolves sensitive, with a floor on the match count so it cannot pass vacuously. Verified by removing an entry — it names the offending route. 3. Dropped the org-secret fingerprint entirely rather than ship a signal that can be wrong. upsertOrgSecret preserves the ORIGINAL createdBy on update, so after a rotation the first creator would be shown a hash of a value they never set and would check it against their stale copy — a false mismatch, which causes exactly the wrong action and is the failure this feature exists to prevent. Meanwhile whoever set the current value would see nothing. Personal secrets are unambiguous (keyed by user_id) and keep the feature. Doing org secrets properly needs an updatedBy column; tracked separately. 4. CLAUDE.md documented the REJECTED design (manager-scoped) rather than what shipped, as did a comment in sensitive-paths.ts. Both corrected. Full gate green: 1680/1680 across 89 files. 🤖 Built with SMT <smt@agora.build>
🤖 Claude Code ReviewSummarySolid, unusually well-reasoned change. The broker-side status extraction is careful in the ways that matter (stderr-only source for a string that becomes a file read, lexical root containment, digits-only propagation, 1. The secret's own MD5 is now in a response body and the DOM — the log rationale applies here too
Separately, this weakens an invariant that held before: an XSS on Owner-scoping ( 2.
|
Follow-up to #133. That change made a failed mint report its real cause; this makes it diagnosable without SSH-ing into the production broker.
Why
Two login failures in production, two days apart, both turned out to be a wrong stored credential — first the email (
brent@agora.op, an invalid TLD), then the password. Each took a round trip to establish, and each was found the same way: exec into the broker container, read aeval's artifacts, look at the error screenshot. That is not a support story.The specific ambiguity that cost the most time: a rejected password and a challenged browser look identical. Same 60s timeout, same screenshot of a filled-in login form, same message. I built a fairly elaborate theory about headless fingerprinting and IP reputation on top of that ambiguity before the actual cause turned out to be a wrong password.
What this adds
1. The failing HTTP status. The one fact that separates those two cases. aeval doesn't report it, but it's in the browser console log inside the artifacts directory — located via aeval's own
Artifacts saved to:banner, which is the line #133 started out by mis-reporting as the error.Only the digits propagate. Nothing else from that log is read, so no credential can ride along and it needs no scrubbing — which is what makes it safe to append after the redaction pipeline has already run.
2. A credential fingerprint — length plus a truncated MD5 — so an owner can check what Vox stored against their own copy:
MD5 and a 10-hex prefix are deliberate. This is a comparison aid, not a security primitive: anything keyed or salted would be unreproducible by the owner and so useless for the one job it has.
3. Where each fact may appear — the security-relevant part:
lastErrorContainer logs get shipped to aggregators, screen-shared and pasted into chat threads — an unsalted MD5 plus an exact length is a practical cracking aid for a weak password. The identifier is different: an email is already visible in aeval's masked output and its error screenshots, so its hash adds diagnosis without adding exposure. And length alone still catches the real case — the production password went from 20 characters to 16.
Notes for review
webSessions.lastError, whichisOwnerOperatedAgent(from fix(broker): report the real login failure, not aeval's artifacts banner #133) already gates, so a marketplace agent receives the status alone.shared/credentials.tsis Node-only (it importscrypto). The client-safe shared module remainsshared/secrets.ts; nothing inclient/imports the former.Verification
Full gate 1671/1671 across 88 files. Both images rebuilt (broker 14.7 kb, loaded via a runtime in-container
import(); daemon 186.9 kb,node --check), client bundle built, dev server restarted so the integration tests exercised the new endpoint rather than the old binary.Each new test was verified non-vacuous by reverting the behaviour it pins:
Generated with SMT smt@agora.io