fix(broker): report the real login failure, not aeval's artifacts banner - #133
Conversation
A failed mint took the last line of aeval's stderr as the error. aeval's last line is an INFO "Artifacts saved to: <path>" banner printed after the diagnosis, so a rejected login surfaced to Core (and to the operator) as a directory path — the same wrong-line-wins defect fixed for the daemon in #128, in the one path where the credential is the thing under test. Reuse summarizeAevalFailure, which prefers loguru ERROR lines, and keep scrubCredentials after it: the summarizer's redaction has a 4-char floor, scrubCredentials has none. Add the broker stage's missing COPY of aeval-output.ts — verified with a real `--target broker` image build, not just tsc. 🤖 Built with SMT <smt@agora.build>
🤖 Codex Code ReviewNo findings. The PR addresses the broker’s misleading error selection without introducing obvious security, logic, performance, or maintainability regressions. The additional broker I did not run the test suite because this environment is read-only. |
🤖 Claude Code ReviewReviewed the merge ref ( The core fix is right: Credential scrub is narrower here than in the daemon — The broker embeds the password into the scenario YAML as // vox-agentd.ts:1888
this.activeSecretValues = Object.values(jobSecrets).flatMap((v) => [v, yamlEscape(v)]);A password like const esc = (v: string) => v.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const needles = [req.email, req.password, esc(req.email), esc(req.password)];( Minor
Nothing blocking beyond the escaped-value scrub, which I'd fix before merge given the message is persisted and user-visible. |
…w value Review catch on #133. The mint scenario embeds credentials as JSON.stringify(value), so a password containing a quote or backslash reaches aeval — and can come back in an ERROR line — escaped. Scrubbing only the raw value missed that, and this PR widened the exposure from one line to up to 500 chars of the ERROR lines most likely to echo step params. The message is logged and returned to Core, where it persists as a user-visible job error. credentialForms() derives the escaped form from the SAME JSON.stringify that writes the YAML rather than a hand-rolled escaper, so the scrub cannot drift from the emitter. (The daemon solves this with a parallel yamlEscape list.) Also: replace the dead `summary || 'login failed'` fallback — summarizeAevalFailure returns 'unknown error', never '' — with an explicit empty-stderr branch, and note that passing '' for stdout is deliberate so nobody wires it through later. 🤖 Built with SMT <smt@agora.build>
|
Addressed the review in e56a0b3. Blocking item — escaped-form credential leak. Fixed, and taken a step further than suggested: rather than hand-rolling an escaper that could drift from the emitter, export function credentialForms(values: string[]): string[] {
return Array.from(new Set(
values.filter((v) => v.length > 0).flatMap((v) => [v, JSON.stringify(v).slice(1, -1)]),
));
}By construction, whatever the scenario emits is what gets redacted — including New test pins it with a password that actually differs when escaped ( Minor 1 — dead const summary = stderr.trim() ? scrubCredentials(summarizeAevalFailure("", stderr, forms), forms)
: "login failed with no output";Minor 2 — the Minor 3 — Verified: Generated with SMT smt@agora.io |
🤖 Codex Code ReviewNo findings. Residual risk: I didn’t run the suite in this read-only environment, so I’m relying on static review. The change looks aligned with the described failure mode: broker failures now prefer aeval ERROR lines, credentials are scrubbed again after summarization, and the broker Docker stage includes the newly imported module. |
🤖 Claude Code ReviewReviewed the merge ref (commits The core change is correct. Reusing One real gap:
const msg = scrubCredentials(raw, [body.email ?? '', body.password ?? '']);Its own comment promises it holds "even if a future mint implementation forgets to scrub" — but that's exactly the case where the escaped form ( Smaller notes (not blocking):
|
…ub too Second review catch on #133. The previous commit fixed the mint path but left the /mint handler's backstop scrub on raw values. That layer exists for "a future mint implementation that forgets to scrub" — which is exactly the case where the JSON/YAML-escaped form arrives, so a raw-only backstop missed the one thing it was there to catch. Its output is logged and returned in the 502 body, which Core persists as a user-visible job error. Strict superset, no happy-path behavior change. Test drives the HTTP layer with a mint that throws an unscrubbed escaped password; verified it fails without the change and passes with it. 🤖 Built with SMT <smt@agora.build>
|
Good catch — fixed in 5c6a168. You are right that the backstop was the wrong layer to leave on raw values: it exists for "a future mint implementation that forgets to scrub," and a mint that forgot to scrub is precisely the path on which the escaped form arrives. Raw-only made it blind to the one case it was there for. const msg = scrubCredentials(raw, credentialForms([body.email ?? , body.password ?? ]));Strict superset, no happy-path change, and the comment now says why it uses the forms rather than the pair. Added an HTTP-level test that drives
On your three non-blocking notes: agreed on all, and no further change made — the Generated with SMT smt@agora.io |
🤖 Codex Code ReviewFindings
Notes
|
🤖 Claude Code ReviewThe fix is well-targeted and the propagation chain checks out: broker 502 body → The fix is conditional on aeval logging to stderr, and the broker only buffers stderr ( The stated reason for not wiring stdout through ("may echo step params verbatim") doesn't hold up asymmetrically: stderr echoes step params too — the second new test ( Minor, non-blocking:
The tests are good — the real-job stderr fixture with the trailing INFO banner is the right regression, and both escaped-form tests include the |
Two review catches on #133. 1. scrubCredentials reduced in array order, so overlapping credentials could shred each other: with email "brent@agora.op" and password "brent@agora.op-2026!", redacting the email first destroys the password's only occurrence and leaves the live "-2026!" remainder in a logged, persisted, user-visible message. Sort needles longest-first (matching summarizeAevalFailure) so whole values beat their substrings. Fixed in scrubCredentials itself rather than at a call site, so the property holds for every caller. 2. The summary was conditional on aeval logging to stderr, while the broker buffered only stderr. That is true of the build we run but is a property of a version and a TTY, not a guarantee — a future aeval routing loguru to stdout would silently return us to an uninformative error, the exact failure this PR removes. Buffer both streams (still never logging either) and pass both. The old "stdout may echo step params" rationale was not asymmetric: stderr echoes them too, and both go through the same two scrub layers. Both tests verified to fail without their fix. 🤖 Built with SMT <smt@agora.build>
🤖 Codex Code ReviewFindings
Notes
|
🤖 Claude Code ReviewThe change is well-reasoned and the fix is correct: Findings, in order of significance: 1. stdout is now a leak surface in a persisted, user-visible string ( The removed line said "stdout intentionally discarded — may echo step params. Never log it." Now The stated justification is hedging against a future aeval that routes loguru to stdout. You can keep that hedge without paying for it in the normal case: const hasStderrDiagnosis = /\|\s*(ERROR|CRITICAL)\s*\|/.test(stderr);
const summary = hasStderrDiagnosis
? scrubCredentials(summarizeAevalFailure('', stderr, forms), forms)
: scrubCredentials(summarizeAevalFailure(stdout, stderr, forms), forms);stdout is consulted only when stderr yielded no diagnosis — which is precisely the "loguru moved to stdout" scenario — and is otherwise never in the reported text. 2. Multi-byte split can defeat the scrub (
const outDec = new StringDecoder('utf8'), errDec = new StringDecoder('utf8');
proc.stdout!.on('data', (d) => { stdout += outDec.write(d); });Pre-existing for stderr, but this PR doubles the exposure and it's cheap to fix in the same place. 3. Unbounded buffering ( Both streams accumulate without a cap for up to 4. Minor: truncation ordering vs. the summarizer's 4-char floor
Tests — good coverage of the actual regression. The fixture is a real captured failure rather than a synthetic one, the |
…tdout out Three review catches on #133. 1. Unbounded capture. /mint is a long-running authenticated endpoint driving a browser, so buffering all output was an OOM waiting for a stuck or noisy run. appendBounded caps each stream at 64 KiB, keeping the TAIL (where the diagnosis is, and what summarizeAevalFailure reads) and cutting forward to a line boundary so a partial line can't strand a credential fragment. 2. Multi-byte scrub bypass. d.toString() decodes each chunk independently, so a UTF-8 sequence split across two pipe reads became U+FFFD — and a mangled non-ASCII password no longer matches its redaction needle. A StringDecoder per stream holds the partial sequence across chunks; both are flushed with .end() before the text is scrubbed. 3. stdout as a leak surface. The previous commit put stdout into a string that is logged, returned in the 502 body, and persisted by Core as a user-visible job error, while the scrub models only two encodings. stdout is now consulted only when stderr carried no diagnosis — precisely the "a future aeval moved loguru to stdout" case the hedge exists for — so normally it contributes nothing to the reported text. hasAevalDiagnosis is exported from aeval-output rather than restating the ERROR/CRITICAL regex in the broker, so the two can't drift. appendBounded is a real export rather than a closure so its boundary behavior is unit-tested. Verified with a real --target broker build plus a runtime import inside the image, confirming esbuild keeps string_decoder external. 🤖 Built with SMT <smt@agora.build>
|
All three addressed in 557da60. 1. Unbounded capture (Codex). Agreed, and it applied to 2. Multi-byte split defeating the scrub (Claude). This one I would not have caught — thank you. 3. stdout as a leak surface (Claude). Took your suggested shape. The predicate is exported from const diagnosisSource = hasAevalDiagnosis(stderr) ? "" : stdout;stdout is consulted only when stderr yielded no diagnosis — exactly the "future aeval moved loguru to stdout" case the hedge exists for — so in the normal case it contributes nothing to the logged/persisted string. Verification. Full local gate: 1613/1617 unit, and the 4 failures are Generated with SMT smt@agora.io |
🤖 Codex Code ReviewFindings
Testing
|
🤖 Claude Code ReviewI couldn't run the test suite (command approval denied), so this is a static review. OverallThe core fix is right and well-targeted: preferring loguru Three things worth addressing. 1. stdout reaches the persisted error in more cases than the comment claims
const diagnosisSource = hasAevalDiagnosis(stderr) ? '' : stdout;The comment says stdout is consulted "exactly the 'a future aeval moved loguru to stdout' case," but the actual condition is stderr has no diagnosis — a strict superset. When neither stream has an That matters because the pre-change code was explicit that this must never happen ( Gating on the condition the comment actually describes keeps the behavior you intended and restores the old guarantee everywhere else: const diagnosisSource = !hasAevalDiagnosis(stderr) && hasAevalDiagnosis(stdout) ? stdout : '';2.
|
…out gate Two review catches on #133. 1. (High) The bounded capture cut mid-line when the retained tail contained no newline. scrubCredentials matches whole credential forms, so a buffer that began inside `password=<secret>` left an unmatchable suffix in a string that is logged, returned in the 502 body, and persisted by Core as a user-visible job error. Enforcing line alignment needs state, not a pure append: the overlong line is now abandoned entirely, AND its continuation is dropped until the next newline — otherwise the very next chunk reintroduces the fragment. Hence createBoundedCapture replacing appendBounded. 2. The stdout gate was a strict superset of what its comment claimed. When NEITHER stream carries a diagnosis (segfault, PyInstaller bootstrap failure, Chromium crash), summarizeAevalFailure falls through to a tail over both strings, putting raw stdout — page dumps, cookie values, storage-state fragments, none of which any needle models — into the persisted error. Now gated on "stdout has a diagnosis stderr lacks", which is the hedge's actual case. Also drop a (diagnosisSource + stderr).trim() that concatenated up to 2xCAPTURE_LIMIT purely to test for emptiness. Per the reviewer's closing note, the security-relevant selection is extracted into pure selectDiagnosisSource/describeMintFailure so the close-handler branch is testable without spawning aeval — including the both-streams-silent case and the 'login failed with no output' fallback, neither of which had coverage. Full gate green: 1619/1619. Image rebuilt and imported at runtime in-container. 🤖 Built with SMT <smt@agora.build>
|
Both fixed in d257cf1. Codex (High) — mid-line cut can start inside a credential. Correct, and the fix needed more than a smarter slice. Enforcing line alignment requires state: abandoning the overlong line is not enough, because the very next chunk is that line's continuation and would be treated as line-start content, reintroducing the fragment. So cap.push("x".repeat(200) + "password=SUPERSECRETVALUE"); // → ""
cap.push("STILL_THE_SAME_LINE_SECRET"); // → "" (continuation)
cap.push("_tail\nERROR | recovered"); // → "ERROR | recovered"All three assertions are in the new test. Claude #1 — the stdout gate was a strict superset of its own comment. Also correct, and the case you named is the dangerous one: when neither stream has a diagnosis, return !hasAevalDiagnosis(stderr) && hasAevalDiagnosis(stdout) ? stdout : "";Claude #2 — Claude closing note — the close-handler branch was untested. Taken. The selection and message pipeline are extracted into pure Verification: Generated with SMT smt@agora.io |
🤖 Codex Code ReviewFindings
Notes
|
🤖 Claude Code ReviewReviewThe change is well-reasoned and the streaming/redaction hardening is solid: Three things worth addressing. 1.
|
Four review catches on #133. 1. The diagnosis regex used \s*, which matches newlines, so it could match ACROSS a line break: "dump: foo |\nERROR |x| y" satisfied hasAevalDiagnosis (run over a whole buffer) while NO single line satisfied the per-line filter. stdout could therefore be admitted and then fall through to summarizeAevalFailure's raw-tail path — leaking the stream the gate exists to quarantine. Now [^\S\n] and per-line anchored with /m, so whole-buffer and per-line agree by construction. 2. Admission of stdout used the lenient predicate, so any page dump containing "| ERROR |" was treated as trusted loguru output. Untrusted admission now requires loguru's full timestamped line shape. The lenient form stays for the stream we already trust, so tightening cannot regress the primary fix if aeval's log format shifts. 3. summarizeAevalFailure ignores needles under 4 chars and truncates to 500, so a short password on that boundary could be cut into a prefix the floorless scrub could no longer match. describeMintFailure now scrubs the INPUTS before summarizing; the scrub on the way out stays as defense in depth. 4. createBoundedCapture discarded the whole buffer when a line was overlong, throwing away complete lines already captured — an ERROR line followed by a 100 KB blob reported "login failed with no output", the exact failure this PR removes. It now abandons only the unsafe partial line and keeps the line-aligned prefix. 🤖 Built with SMT <smt@agora.build>
|
All four addressed in b31991d. Claude #1 — Codex P2 — unanchored match lets stdout in. Same root, different consequence. Rather than tighten one predicate and risk regressing the primary fix if aeval's log format shifts, I split them by trust level:
A page dump containing Codex P3 — 500-char truncation cutting a sub-4-char credential. Fixed at the root instead of deferred again: Claude #2 — bounded capture discarding a captured diagnosis. Correct, and it produced precisely the regression this PR exists to prevent: One correction to my earlier comments. I reported "tsc clean" several times on this PR. That claim was empty:
Verification here: 29/29 broker suite, image rebuilt Generated with SMT smt@agora.io |
🤖 Codex Code ReviewFindings
Notes
|
🤖 Claude Code ReviewSummaryThe change is well-constructed and the security reasoning in the comments matches what the code actually does. I verified the three moving parts by hand:
The Dockerfile addition is required and correct — FindingsAdmitted stdout is line-selected leniently ( const source = selectDiagnosisSource(stdout, stderr);
// e.g. keep only lines the strict predicate accepts when source === stdoutNot a new leak class (admission already grants the stream), so treat as a precision improvement rather than a blocker. Detached doc comment (
Nothing else: no auth, injection, or credential-exposure issues introduced, and the test file covers each of the claimed invariants with fixtures that actually exercise them (I traced the capture, regex, and scrub assertions manually — they hold). I was unable to execute the suite in this environment, so the pass/fail claim is from static tracing, not a run. |
…trictly Two review catches on #133. 1. My previous commit scrubbed the summarizer's INPUTS to beat the 4-char floor. That rewrites the very tokens classification depends on: with a password of "E", every "ERROR" becomes "[redacted]RROR", DIAGNOSIS_LINE stops matching, and the summarizer falls back to its tail path and reports the artifacts banner again — reintroducing the exact bug this PR fixes. summarizeAevalFailure takes an optional minNeedleLength instead (default 4, unchanged for the daemon; the broker passes 0). It already redacts AFTER choosing lines and BEFORE truncating, so removing the floor closes the short-credential gap without touching the text classification reads. Also guards v.length >= 1 independently: a floor of 0 would otherwise admit the empty string, and "".split("") splits between every character. Regression test uses passwords "E", "R", "|", "ERROR" and asserts the real diagnosis still wins; verified it fails with the pre-scrub restored. 2. Admitting stdout was strict but SELECTING from it was lenient: the summarizer picks lines with the lenient predicate and keeps the last three, so page-dump text containing a bare "| ERROR |" could both reach the persisted error and bury the loguru line that earned admission. selectDiagnosisSource now hands over only loguru-shaped lines, making selection as strict as admission. 🤖 Built with SMT <smt@agora.build>
|
Both fixed in 09866d7. Codex — pre-scrubbing the input can destroy classification. You are right, and this was a bug I introduced one commit earlier while fixing the truncation gap. Scrubbing the input rewrites the tokens the classifier reads: with a password of The right lever was the floor, not the input. One thing your suggestion surfaced indirectly: a floor of 0 admits the empty string, and Regression test runs passwords Claude — admitted stdout was line-selected leniently. Correct, and worth closing even as a precision issue: the summarizer keeps the last three matching lines, so trailing page-dump text with a bare Verification: 32/32 broker suite, Generated with SMT smt@agora.io |
🤖 Codex Code ReviewNo findings.
Testing note: I could not run the targeted test locally because dependencies are not installed in this checkout: |
🤖 Claude Code ReviewI reviewed the merge ref: VerdictThe change is correct and the security reasoning holds up under tracing. I could not find a way to leak a credential or smuggle untrusted stdout into the persisted error. Findings below are minor. Things I specifically verified:
Minor findings1. Orphaned JSDoc — The "Pick the most informative line… Order of preference: 1. … 2. …" block that documented 2. Timeout path reports no captured output — finish(() => reject(new Error(`login timed out after ${timeoutMs}ms`)));
3.
4. Empty return means both "stdout is empty" and "stdout is rejected". Non-issues I checked and dismissed
The test additions are unusually good — the "password is a substring of |
🤖 Claude Code ReviewSummarySolid, well-reasoned change. The core fix (prefer loguru I verified the pieces that looked most likely to be subtly wrong and they hold up:
Findings below, in severity order. The daemon path did not get the StringDecoder fix
proc.stdout.on('data', (data) => { stdout += data.toString(); ... });
proc.stderr.on('data', (data) => { stderr += data.toString(); ... });This is the exact scrub bypass the broker's new Failure scenario: a job secret containing a non-ASCII character ( This PR touches line 878 (the summarize call) and 1899 ( Related and lower priority: those buffers are still unbounded while the broker got Core's re-redaction backstop uses raw values only
detail = [req.email, req.password]
.filter(...)
.sort((a, b) => b.length - a.length)
.reduce((acc, v) => acc.split(v).join("[redacted]"), detail);The comment says this exists so "a stale or buggy broker echoing a credential should not become a durable leak here." But this PR makes precisely the opposite argument two files over, when upgrading the broker's own backstop:
A stale broker image (the realistic case — Core and sidecar deploy independently) is one that predates Core can't import
|
Three review catches on #133. 1. reduceUrlsSafely leaked the suffix when a URL-valued secret was a PREFIX of the echoed URL. Secret "wss://h/rtc" appearing as "wss://h/rtc?access_token=JWT" became "[redacted]?access_token=JWT", which reduceUrlsToHost no longer recognized as a URL, so the query survived. The pre-redaction now consumes the rest of the URL run, not just the needle. 2. The daemon never got the StringDecoder fix, on the path with strictly more secret material than the broker's single login pair: data.toString() per chunk decodes a split UTF-8 sequence to U+FFFD on each side, so a non-ASCII secret stops matching its needle and the fragment reaches the job's persisted error. Its buffers were also unbounded while the broker got a cap. Both fixed by reusing the broker's own capture, which moves into aeval-output.ts alongside the summarizer. 3. Core's re-redaction backstop used the raw pair while the broker's used every encoding — so the backstop covered less than the layer it backstops, and was weakest in exactly the case that calls for it (the broker failing to scrub is when an escaped or URL-encoded spelling arrives). That last one is really three implementations of the same thing drifting, which is what every round of this review kept finding. shared/credentials.ts now owns urlForms, credentialForms and redactValues; Core imports it via @shared/credentials, the daemon and broker via ../shared/credentials with a matching Dockerfile COPY, the arrangement shared/secrets.ts already uses. scrubCredentials stays as a named alias since it reads better at its call sites. The daemon keeps yamlEscape alongside, because that is what its own resolveSecrets substitutes. Local gate: 1646/1651. Four are agora-e2e's ConvoAI orphan — a production bug fixed in #136, not on this branch; clearing the leaked session makes that file 13/13. The fifth moves between files run to run (#134 contention) and passes in isolation. 🤖 Built with SMT <smt@agora.build>
|
All three fixed in bc488fb. 1. URL-valued secret as a PREFIX (Codex). Correct, and it defeated the guard I had just added: 2. The daemon never got the 3. Core's backstop used raw values only. The sharpest observation in this review, because it names the shape of the whole problem: a backstop covering fewer encodings than the layer it backstops is weakest in exactly the case that calls for it. The broker failing to scrub is precisely when an escaped or URL-encoded spelling arrives. Rather than widen Core's copy, I removed the third copy. That is the same drift you and Codex have flagged in four different guises across this review (client/server name heuristic in #135, broker/daemon URL guard, broker/daemon capture, Core/broker needles). Consolidating was overdue. Verification: Local gate 1646/1651, and all five are accounted for: four are Generated with SMT smt@agora.io |
🤖 Claude Code ReviewReviewed the full diff against the merge base ( Overall this is careful, well-reasoned work. The redaction consolidation into Findings below. 1.
|
Four review catches on #133. 1. Core truncated the broker's error body to 500 chars and THEN redacted it — the exact ordering this change argues against everywhere else, in the one place that only runs when the broker failed to scrub. A credential straddling offset 500 left a partial value matching no whole needle, on a string persisted to web_sessions.last_error. Reordered, with a test that places the credential across the boundary and fails under the old order. 2. The comment claiming the mint-timeout env read was "kept in step" with auth-session.ts was not true: only the new copy validated it. mintTimeoutSeconds() now lives in broker-registry.ts — the side auth-session.ts already imports — and auth-session.ts re-exports it, so staleMintThresholdSeconds() and the AbortSignal are derived from one validated number instead of two. 3. aeval-output.ts imported urlForms/redactValues while re-exporting them independently, so the import was dead. Dropped — and summarizeAevalFailure's hand-rolled scrub now delegates to redactValues, which is the same logic and was the last remaining copy after this PR consolidated the rest. 4. Noted at the resolveAevalOutputDir call site that bounding the daemon's capture can evict aeval's early "Session directory:" line on a very noisy run, leaving the newest-by-name fallback. That degrades rather than breaks for a single in-flight run, but it is a change from the unbounded buffer and was undocumented. Local gate: 1648/1652 with everything healthy. All four failures are agora-e2e's ConvoAI orphan — the production bug fixed in #136, not on this branch. 🤖 Built with SMT <smt@agora.build>
|
All four fixed in c8e7fa8. 1. Truncate-before-redact in Core. Caught cleanly — it was the exact inversion this change argues for elsewhere, in the one place that only executes when the broker has already failed to scrub. Reordered, and I added the boundary case you noted was missing: the credential is positioned across offset 500, and the test fails under the old order. 2. "Keep the two in step" was not held. Right — only the new copy validated. Rather than validate both, 3. Dead import, and the last hand-rolled scrub. Both gone. The second half is the better catch: 4. Verification: Local gate 1648/1652, and all four failures are Generated with SMT smt@agora.io |
🤖 Claude Code ReviewSummarySolid, well-reasoned change. The core fix — preferring loguru Four things worth acting on. 1. Bounding the daemon's capture silently degrades the stdout metrics fallback (medium)
let currentPhase = 'response'; // default phase until we see a phase markerOn a run whose combined output exceeds The 2.
|
…t detail Four review catches on #133. The first is a regression this PR introduced. 1. Bounding the daemon's capture silently corrupted the stdout METRICS fallback, on the exit-code-0 path. parseAevalStdout walks the whole event log with a phase state machine that defaults to 'response' until it sees a phase marker, so a tail-truncated buffer makes it resume mid-run in the wrong phase: interrupt turns counted as response latencies, early turns lost. Wrong numbers, reported as success — which contradicts the no-partial-metrics policy stated two lines above it. createBoundedCapture now exposes `truncated`, and the daemon refuses the fallback when metrics.json is absent AND the capture overflowed, failing the job instead of inventing latencies. metrics.json is read from disk and is unaffected, so the common path does not change. 2. mintTimeoutSeconds()'s doc argued it existed because AbortSignal.timeout takes [EnforceRange] unsigned long long, but it only rejected NaN and <= 0 — 1e20 passed and still threw, failing every mint with an opaque error. Clamped at both ends. 3. The daemon's timeout path still reported only the deadline, while the broker's identical hang path says what it captured. Same operator complaint one file over, and the captures were already in scope. It now reports them, reading completeText so a half-written line can't strand a credential fragment. 4. reduceUrlsSafely's continuation class swallowed '|'-delimited text after a URL-valued needle. Over-redaction only, but free to fix. Local gate: 1652/1653. The one failure is tier-pool-claim's reaper case racing the live daemon for a pooled job; it passes in isolation (#134). 🤖 Built with SMT <smt@agora.build>
|
All four fixed in 091c339, and #1 was a regression this PR introduced — thank you for tracing it to the success path. 1. Bounding the daemon capture corrupted the stdout METRICS fallback. You are right that this is a different class from the Took your suggested shape: 2. 3. The daemon timeout path still reported nothing. Fixed, mirroring the broker — including reading 4. Verification: Local gate 1652/1653 — including Generated with SMT smt@agora.io |
🤖 Claude Code ReviewI reviewed the merge ref statically (the sandbox blocked Overall this is solid, security-conscious work. The redaction centralization in Findings below, most severe first. 1. The broker's own mint timeout is still unvalidated —
|
…r a blob Four review catches on #133. 1. The broker sidecar read WEB_SESSION_MINT_TIMEOUT_SECONDS with a bare parseInt — the third copy, and the one that fails worst: setTimeout(fn, NaN) coerces the delay to 0, so a malformed value makes the broker SIGTERM its child on the next tick and every mint dies instantly, while Core (which clamps) waits ~195s for a broker that already gave up. shared/mint-timeout.ts now owns the clamp and all three read it, the same shared-module + Dockerfile-COPY pattern shared/credentials.ts established. 2. createBoundedCapture evicted captured lines for a line it then discarded. A huge single line arrives across many chunks, each under the cap, so each hit the no-terminator branch and called evictOldest() — emptying `complete` to make room for a line that is dropped anyway. That destroys the diagnosis for output that never enters the buffer, the exact outcome the overlong-line drop exists to prevent. That branch no longer evicts; `partial` is capped independently, so retained text is bounded by 3*limit instead of 2*limit. 3. Two comments now contradicted the code: broker-registry's "the env read is duplicated ... keep the two in step" (there is no second copy), and the Dockerfile's broker-stage "no shared/" (two shared modules are COPYed in). 4. Minor: Core buffered a failing broker's body with res.json() before applying the 500-char cap — now res.text() + slice + parse. And activeSecretValues was not deduped, so an alphanumeric secret contributed ~6 identical needles. Local gate: 1653/1654, the one failure being the known credits contention that passes in isolation. 🤖 Built with SMT <smt@agora.build>
🤖 Claude Code ReviewOverall this is careful, well-reasoned work. The 1. Truncation guard rejects runs whose metrics are on disk under
|
Four review catches on #133. 1. The truncation guard checked only outputDir/metrics.json, but parseAevalResults accepts analysis/metrics.json and report.json as well — both read from disk, both unaffected by a truncated console capture. So the guard would have failed a perfectly good exit-0 run whose analysis output landed under analysis/. It now mirrors the full list. 2. CAPTURE_LIMIT was 64 KiB, which a multi-minute voice eval passes easily. At that size the daemon's early "Session directory:" line is evicted on ordinary runs — making resolveAevalOutputDir's primary path effectively dead in production rather than a rare fallback — and the guard above would fire routinely rather than on runaway output. Raised to 1 MiB, which still bounds memory, since an OOM bound is all it was ever for. 3. The comment on Core's res.text() + slice claimed to stop Core buffering an unbounded body. It does not: text() buffers first, so the cap bounds what is RETAINED. Reworded to say that, and why the residual is accepted (internal authenticated peer, AbortSignal bounds the read). 4. The daemon computed its timeout summary unconditionally and threw it away on the non-timeout branch, which then summarized again — two full passes per needle over both buffers. Moved inside the branch, and the emptiness test no longer concatenates both buffers. Local gate: 1650/1654, all four being agora-e2e's ConvoAI orphan (fixed in #136, not on this branch). 🤖 Built with SMT <smt@agora.build>
|
All four fixed in 6389605. 1. The truncation guard checked one path where 2. 3. The 4. Unconditional Verification: On CI: Generated with SMT smt@agora.io |
🤖 Claude Code ReviewReviewed the full diff against Findings1. The truncation guard checks file existence, not usability — const metricsOnDisk = diskSources.some((f) => fs.existsSync(f));
if (!metricsOnDisk && (outCap.truncated || errCap.truncated)) { fail(...) }
const results = this.parseAevalResults(outputDir, allOutput);
Gating on the parse result rather than const results = this.parseAevalResults(outputDir, allOutput);
if (results.source === 'stdout' && (outCap.truncated || errCap.truncated)) { fail(...); return; }(needs a 2. Stale comment in the broker stage —
Notes (no action needed)
Everything else — the |
…xists Two review catches on #133. 1. The guard tested fs.existsSync, but parseAevalResults does not commit to a disk source merely because the file is there: a metrics.json failing hasLatency() and isAnalysisOutput(), or a report.json without latency, both fall through to parseAevalStdout. So a present-but-structureless metrics.json plus a truncated capture waved through exactly the case the guard exists for — the phase state machine resuming mid-run in its default 'response' phase, counting interrupt turns as response latencies, silently, on the exit-0 path. parseAevalResults now records whether it fell through to stdout, and the guard reads that instead. Existence was never the question; usability was. 2. The Dockerfile's broker stage said aeval-output.ts is "itself import-free". It imports ../shared/credentials, and the stage's COPY of that file is what makes the bundle work — so the comment would have led an editor to drop a load-bearing line. Same hazard the paragraph beside it warns about for the --external list. Local gate: 1653/1654, the one failure being the known credits contention that passes in isolation. 🤖 Built with SMT <smt@agora.build>
|
Both fixed. 1. The guard tested existence, not usability. Correct, and it is the right distinction: 2. The Dockerfile "itself import-free" comment. Fixed, and the hazard you name is exactly right — that COPY is load-bearing, and the comment would have told a future editor it was not, in the same paragraph that argues a partial Verification: both images rebuilt (broker Generated with SMT smt@agora.io |
🤖 Claude Code ReviewReviewed the full diff ( Overall this is solid, and unusually well-reasoned. The security-relevant parts hold up under adversarial reading:
Real findings, in order: 1. The mint timeout has a fourth reader that wasn't unified —
2. The comment says "Max characters retained per captured stream," but the eviction hysteresis lets 3. Comment inaccuracy in "+30s of headroom over the abort" — the abort is Nits, take or leave:
|
Three review catches on #133. 1. shared/mint-timeout.ts claimed "one definition for all three readers" while a FOURTH deadline existed: the daemon's hard-coded 240s session poll. With the ceiling at 3600 any configured value above ~225 inverted the chain — the agent gives up first, fails the job with a generic "timed out waiting for session mint", and the diagnosis this PR exists to surface never reaches the job error while Core and the broker are still legitimately working. The daemon runs on another host and cannot read Core's env, so the fix is a ceiling low enough that the agent always outlasts the mint: MAX_MINT_TIMEOUT_SECONDS is 200, giving 200 < 215 (abort) < 230 (stale) < 240 (agent). Both ends of the chain now say so, and say that raising one means raising the other. Structural rather than a note asking someone to remember. 2. CAPTURE_LIMIT's docstring said "max characters retained", but the eviction hysteresis allows 2x plus another 1x in `partial` — ~3x per stream, ~6x per in-flight run. It is a retention target, not a ceiling, and it is the number someone will size a sidecar against. 3. staleMintThresholdSeconds' comment claimed "+30s of headroom over the abort". The abort is +15, so the headroom beyond it is 15s. The value was right, the arithmetic in the justification was not — in a change arguing these numbers must not drift, that is the comment most likely to be trusted verbatim. Nits from the same review: the truncation-guard message now says output "was discarded (capture limit, or a single overlong line)", since `truncated` is set in both cases; and the daemon flushes its decoders on the close path, matching the broker. Local gate: 1650/1654, all four agora-e2e's ConvoAI orphan (#136, not on this branch). 🤖 Built with SMT <smt@agora.build>
|
All three fixed in 152aba5. 1. A fourth reader of the mint deadline. The best catch of this round — Since the daemon runs on another host and cannot read Core's env, I made the ordering structural instead of documented: the ceiling is now 200, so the chain is 2. 3. Nits: guard message now says output "was discarded (capture limit, or a single overlong line)" since Verification: both images rebuilt (broker 13.8 kb Generated with SMT smt@agora.io |
🤖 Claude Code ReviewI reviewed the diff against the merge base ( Overall this is careful, well-reasoned work. The core fix (prefer loguru Findings, roughly by significance: 1. An operator setting 2. Pre-flush snapshot, and inconsistent
Separately, 3. Core's re-redaction is credential-only, not URL-reduced ( The comment argues the case for not trusting the broker's scrub ("a stale or buggy broker echoing a credential must not become a leak here") and then applies only half of the broker's sanitization. 4. Behavior change worth validating before merge: bounded capture can newly fail jobs that used to complete The 5. Low:
6. Nit: One process note: the comment density in |
Picks up #133 (broker failure reporting + shared/credentials.ts). No overlap with this branch's shared/secrets.ts change; git auto-merged server/auth-session.ts, where #133 added a mintTimeoutSeconds re-export and this branch swapped the local AUTH_FIELD_RE for the shared isAuthFieldName. 🤖 Built with SMT <smt@agora.build>
codex-code-review has been hanging: the job completes every step, then stalls until GitHub's 60-minute default kills it. It blocked PR #133 and several since. Root cause is a dependency we never pinned. codex-action's `codex-version` input defaults to empty, so every run installs whatever npm's `latest` is at that moment — pinning the action to @v1 buys nothing, because the binary that actually executes is fetched fresh each run. Correlating npm publish times against 285 runs of this workflow: 0.149.0 published 2026-08-20 21:09Z 20 runs, 0 hangs, max 198s 0.149.1 published 2026-08-24 00:32Z 4 of the next 9 runs hung (~3600s) Zero runs exceeded 15 minutes between 2026-04-28 and 2026-08-23, and the hangs continue through 0.150.0, 0.150.1, 0.151.0 and 0.152.0. Nothing changed on our side — this file was last edited 2026-06-16, and the median run time never moved (~120-160s), so this is a step change on a date we did not touch, not gradual degradation. The ~44% hit rate rather than 100% fits a race in process cleanup, which matches the runner's last words before each stall: Cleaning up orphan processes Terminate orphan process: pid (2376) (MainThread) Caveat worth stating: there were no codex runs between 08-23 10:38Z and 08-24 22:25Z, so the changeover itself is unobserved — the "first hang" is just our first run after the publish. The pin is therefore also the experiment. If hangs stop, causation is established. Reported upstream. timeout-minutes caps the damage at 10 rather than 60 minutes. It is not specific to this bug and stays regardless of the pin; the healthy maximum across all 285 runs is ~400s. Claude's workflow gets the same guard at 15/5 minutes — it has never hung, but it runs the same class of unpinned model-driven step. Deliberately NOT changed: posting the review stays inline in the same job. An earlier draft moved it to an artifact plus a separate posting job so a review would survive the hang, but in every observed hang the comment posted fine — posting runs before the stall. That solved a failure that never happened, at the cost of an extra job and an artifact round-trip. 🤖 Built with SMT <smt@agora.build>
codex-code-review has been hanging: Codex prints its final message and token count, the step then emits no `##[end-action]`, and the job idles until GitHub's 60-minute default kills it. 287 runs since 2026-04-28 never exceeded 15 minutes; since 2026-08-24T22:25Z there have been 18 hangs of 23-64 minutes. It blocked PR #133 and several since. Cause is upstream, in the action rather than in anything of ours: the floating `@v1` tag moved to v1.12 (86365089) on 2026-08-20T23:38:51Z, and v1.12 rewrote the privilege-isolation launch path. It spawns the CLI with inherited stdio and waits on the child's `close` event, so a descendant outliving the turn keeps those descriptors open and the action never returns. Tracked upstream as openai/codex-action#150 and #169; a wrapper fix using private pipes and completing on `exit` is proposed in their #151. So pin to v1.11 (52fe01ec), which is what other affected orgs are running. Worth recording how this was nearly mis-diagnosed, because the trap is generic. The first pass here blamed an unpinned CLI: `codex-version` defaults to empty, so every run installs whatever npm `latest` is at that moment, and 0.149.1 published 2026-08-24T00:32Z — 22 hours before our first hang. That fit, and it was wrong. Our 20 clean runs before the boundary were all light (max 3 min), and the failure is workload-sensitive, so the CLI-version boundary and the action-version boundary are perfectly confounded in our data: both explain it equally well and our runs cannot separate them. What separates them is evidence we do not own — another org hit the same hang on codex-version 0.147.0, which predates the suspect release, and a third has 145/145 clean on v1.11 against 69/74 on v1.12 with model and effort held fixed. The intermittency is the reason a green run proves nothing: on 2026-08-31 the same PR succeeded in 2 min at 14:38, hung 63 min at 14:45, and succeeded in 3 min at 15:05. timeout-minutes caps the damage at 10 minutes rather than 60. Note it is at JOB level deliberately — `timeout-minutes` does not apply to a step that `uses:` a composite action, so a step-level value here would silently do nothing. Claude's workflow gets the same guard at 15/5 minutes; it has never hung, but it runs the same class of model-driven step behind a floating tag. Deliberately NOT changed: posting stays inline in the same job. An earlier draft moved it to an artifact plus a separate posting job so a review would survive the hang, but in every observed hang the comment posted fine — posting runs before the stall. That solved a failure that never happened. 🤖 Built with SMT <smt@agora.build>
codex-code-review has been hanging: Codex prints its final message and token count, the step then emits no `##[end-action]`, and the job idles until GitHub's 60-minute default kills it. 287 runs since 2026-04-28 never exceeded 15 minutes; since 2026-08-24T22:25Z there have been 18 hangs of 23-64 minutes. It blocked PR #133 and several since. Cause is upstream, in the action rather than in anything of ours: the floating `@v1` tag moved to v1.12 (86365089) on 2026-08-20T23:38:51Z, and v1.12 rewrote the privilege-isolation launch path. It spawns the CLI with inherited stdio and waits on the child's `close` event, so a descendant outliving the turn keeps those descriptors open and the action never returns. Tracked upstream as openai/codex-action#150 and #169; a wrapper fix using private pipes and completing on `exit` is proposed in their #151. So pin to v1.11 (52fe01ec), which is what other affected orgs are running. Worth recording how this was nearly mis-diagnosed, because the trap is generic. The first pass here blamed an unpinned CLI: `codex-version` defaults to empty, so every run installs whatever npm `latest` is at that moment, and 0.149.1 published 2026-08-24T00:32Z — 22 hours before our first hang. That fit, and it was wrong. Our 20 clean runs before the boundary were all light (max 3 min), and the failure is workload-sensitive, so the CLI-version boundary and the action-version boundary are perfectly confounded in our data: both explain it equally well and our runs cannot separate them. What separates them is evidence we do not own — another org hit the same hang on codex-version 0.147.0, which predates the suspect release, and a third has 145/145 clean on v1.11 against 69/74 on v1.12 with model and effort held fixed. The intermittency is the reason a green run proves nothing: on 2026-08-31 the same PR succeeded in 2 min at 14:38, hung 63 min at 14:45, and succeeded in 3 min at 15:05. timeout-minutes caps the damage at 10 minutes rather than 60. Note it is at JOB level deliberately — `timeout-minutes` does not apply to a step that `uses:` a composite action, so a step-level value here would silently do nothing. Claude's workflow gets the same guard at 15/5 minutes; it has never hung, but it runs the same class of model-driven step behind a floating tag. Deliberately NOT changed: posting stays inline in the same job. An earlier draft moved it to an artifact plus a separate posting job so a review would survive the hang, but in every observed hang the comment posted fine — posting runs before the stall. That solved a failure that never happened. 🤖 Built with SMT <smt@agora.build>
Found while diagnosing job 31072 in production.
What happened
With the
BROKER_ADVERTISE_URLfix in place, Core reached the broker and a mint finally ran end-to-end. It failed, and this is what Core recorded:A directory path. The actual cause was two lines earlier in the same stream, and only visible by exec-ing into the container:
i.e. the login was rejected and the browser never left the SSO page.
Cause
mintWithAevaltookstderr.trim().split("\n").pop(). aeval's last line is an INFO banner printed after the diagnosis, so the one line kept was structurally guaranteed to be the least useful one — the same defect fixed for the daemon in #128, here in the one path where a credential is the thing under test.Fix
summarizeAevalFailure(prefers loguru ERROR lines, takes the last ones).scrubCredentialsafter it: the summarizer's redaction has a 4-char floor,scrubCredentialshas none, so a very short password still gets redacted.COPY vox_eval_agentd/aeval-output.ts— the module was daemon-only. Verified with a realdocker build --target broker, not just tsc; this is the exact missing-COPY class of bug that broke the agentd image in fix: surface the real cause of a missing-secret run instead of a PyInstaller banner #128.Tests
Two new cases in
tests/auth-session-broker-service.test.tspinned to the verbatim stderr tail from the real job-31072 failure: the reported message must name the login failure and must not be the artifacts banner, and the credential must not survive into it.Full local gate green: 1611/1611 across 88 files. (Getting there required clearing ~244 leaked test workflows from the dev DB — the suite creates workflows without cleanup and trips the 200-workflow principal cap after ~22 runs. Filed separately.)
Generated with SMT smt@agora.io