fix: surface the real cause of a missing-secret run instead of a PyInstaller banner - #128
Conversation
…staller banner
A run whose workflow references an unconfigured ${secrets.X} failed with
"aeval exited with code 1: [PYI-3739:ERROR] Failed to execute script
'pyi_entrypoint'" — a Python packaging artifact. The real diagnosis existed
at four points and every one dropped it (reported as job #31006).
A. Reject at dispatch, naming the secrets. Core already computed `present`
for every referenced placeholder and ignored it. New shared helper
missingSecretNames(); enforced at ALL four job-minting entry points —
run route, v1 run, schedule create, schedule run-now — so schedules can't
keep emitting a doomed job every tick.
B. Daemon fails fast on an unresolved placeholder, before spawning aeval.
Scans post-substitution rather than instrumenting resolveSecrets, because
substitution is SKIPPED entirely when the secrets map is empty — precisely
the reported case. Catches a secret deleted between dispatch and claim.
C. Replace "last line of stderr" with summarizeAevalFailure(). That heuristic
is structurally wrong for a PyInstaller binary: its generic banner is
printed AFTER the traceback, so the one line kept was guaranteed to be the
least useful. Now prefers aeval's own loguru ERROR lines (scanning BOTH
streams, since its sink may be either), else the noise-filtered tail;
length-capped. Lives in shared/ so tests don't import the daemon
entrypoint (which runs main()).
D. Client: show the missing secrets and disable Start. Also fixes a latent
bug this uncovered — both dialogs typed referencedSecrets with `class`
while the server sends `brokerType`, so `s.class === "runtime"` was always
false: the runtime-secret exposure warning and its consent checkbox have
never rendered, making a shared-tier dispatch of any workflow with runtime
secrets unsatisfiable from the UI (server requires the consent flag).
Tests: dispatch gate 400s and names the secret on both the run route and
schedule create (fixture mirrors the real failure — the ref lives in the
workflow's stepsPrefix / platform.setup); summarizeAevalFailure recovers
"Unknown variable source: secrets" from the reported log verbatim and never
returns the pyi_entrypoint banner, on either stream, with a length cap.
🤖 Built with SMT <smt@agora.build>
🤖 Codex Code ReviewFindings
Notes
|
🤖 Claude Code ReviewSummaryThe core idea is right — the PyInstaller-banner heuristic really was structurally guaranteed to pick the least useful line, and gating at dispatch beats burning an agent run. Blocker
For a workflow that needs a Core-minted session, Net effect: every auth-broker workflow now fails at claim time with Fix: move the scan below the session-injection block (after Other findingsScheduler tick isn't gated ( Message is wrong for the public-org-workflow / non-member runner case. Nit — Low — error-string surface. The client changes ( |
…ild resolves it CI's build-vox-eval-agentd failed with `Could not resolve "../shared/aeval-output"`: the Dockerfile copies shared/ files INDIVIDUALLY (secrets.ts, metrics.ts), so a new shared module is invisible to the esbuild bundle step. tsc can't catch this — only a real image build can. The placement was wrong anyway: shared/ should mean "used by more than one deployable", and nothing outside the daemon uses summarizeAevalFailure. Moved to vox_eval_agentd/aeval-output.ts (still its own module, so tests import it without pulling in the entrypoint that runs main()) and added the matching COPY line. Verified with a real local build (repo-root context, --target daemon): succeeds, and the helper is present in the bundled /app/vox-agentd.js. 🤖 Built with SMT <smt@agora.build>
🤖 Codex Code ReviewFindings
No other real issues found in the reviewed diff. |
🤖 Claude Code ReviewFindings1. Blocker — the daemon's unresolved-placeholder check breaks every session-injected (brokered login) job
For a brokered workflow, Net effect: the new check throws for every auth-session job, Fix: move the scan below the The same conflation hits a second, milder case: an org workflow run by a non-member gets no org secrets by design ( 2. The recurring scheduler tick isn't gatedThe create-time check ( 3. Low —
|
…gate the scheduler tick
Both review bots caught a blocker I introduced.
BLOCKER — the daemon's unresolved-placeholder scan ran BEFORE the session
injection block, so it would have failed every session-injected (brokered
login) job. Those jobs are exactly the case where ${secrets.EMAIL} /
${secrets.PASSWORD} are still literal at substitution time: Core withholds
brokered login secrets from /jobs/:id/secrets by design (the agent must never
hold durable credentials), and injectStorageSession only rewrites them away
afterwards, forcing setup:storage. Moved the scan to just before framework
dispatch, where the strings are final. Added a test in session-inject pinning
the invariant the ordering depends on: post-injection, no ${secrets.*}
survives a credential-bearing platform.setup.
Scheduler tick was a FIFTH job-minting path I missed (run route, v1 run,
schedule create, run-now were covered). A secret deleted after a recurring
schedule was created would emit a doomed job every tick; it now disables the
schedule with the secret names, matching the existing misconfigured/pool
handling.
313 passing across session-dispatch, tier-pool-dispatch,
session-capability-gate, api.test; session-inject + eval-agent-daemon 99.
🤖 Built with SMT <smt@agora.build>
🤖 Claude Code ReviewSummarySolid, well-motivated fix. The diagnosis (PyInstaller's banner is structurally always the last stderr line, so "last line of stderr" was guaranteed to be the least useful one) is correct, the daemon-side scan is placed correctly relative to session injection (and the new Worth calling out: the Findings1.
2. "First 3 ERROR lines" can report a transient error instead of the fatal one (medium)
3. The Core gate and the runtime secrets path disagree for public org workflows (medium)
4. Brokered-secret misuse produces the same wrong message on non-console paths (low-medium)
5. Scheduler disables permanently on a transient window (low-medium)
6. Copy assumes the runner is the workflow owner (low)
Nits
Test coverageThe regression tests are good but only cover the console run route and schedule-create. The api-v1 gate, the run-now re-check, and the scheduler-tick disable are all untested. The scheduler one is the highest-value gap given finding #5. |
…rror; honest copy Security (finding #1) — summarizeAevalFailure now captures up to 3 ERROR lines into the job's persisted, user-visible error, and resolveSecrets substitutes DECRYPTED values into the YAML aeval sees. An ERROR line echoing step params could therefore have leaked a live credential into the console. The summarizer now scrubs every known value of the active job's secrets (values under 4 chars skipped so a short value can't shred the message); the daemon holds them only for the job's duration and clears them in finally. #2 — take the LAST error lines, not the first. In a long run an early recoverable ERROR would otherwise bury the fatal one, which is the very "wrong line wins" failure this helper exists to fix. Identical for short traces like the reported one. #3/#6 — the daemon cannot distinguish "no such secret" from "the server withheld it for this job" (an org secret is additionally fenced on the job creator's membership), so its message no longer tells the user to go create one. Server and client copy likewise stop saying "you have not configured": secrets resolve in the WORKFLOW OWNER's scope, so someone running another user's public workflow cannot fix it themselves. Nits: missingSecretNames no longer orphans classifyReferencedSecrets' doc comment; api-v1 reuses the already-bound scope; stray blank lines. Tests: redaction (value scrubbed, surrounding diagnosis kept, short values ignored) and last-error preference. 🤖 Built with SMT <smt@agora.build>
🤖 Claude Code ReviewThe change is well-structured: the dispatch gate lands before 1. Multi-line secret values escape redaction (
|
…e to what the daemon resolves Round 2 of review findings. #1 Multi-line secret values escaped redaction. The summary is built from already split+trimmed lines, so no single line contains a PEM key or JSON blob and the whole-value match never fired — fragments reached the persisted error. Now redacts each LINE of every value as well, longest-first so whole values win over fragments. Floor raised 4 → 8 chars (#4): a value like "8080" or "prod" would otherwise shred unrelated text. #2 activeSecretValues was assigned OUTSIDE the try whose finally clears it, so a throw in between (e.g. from resolveSecrets) left decrypted values resident until the next job overwrote them — the opposite of what the comment promised. Moved inside the try. #3 Narrowed the Core gate to the four fields the daemon actually resolves (scenario, app, stepsPrefix, stepsSuffix) instead of the whole config. I had accepted this over-approximation in the PR body, but the review surfaced a consequence I had underweighted: a stray placeholder in an unresolved field would now PERMANENTLY DISABLE a recurring schedule on its next tick, with only a log line to show for it. Gate and daemon now agree by construction. Picked per-config rather than via mergeEvalConfig, which throws on conflicting keys and would turn a clean 400 into a 500. The run route filters its existing `classified` array, so no extra query. Deferred: one extra full-scope secrets read per due schedule per tick (#5) — noted, not urgent at current schedule counts. 362 passing across tier-pool-dispatch, session-dispatch, api.test, secrets, session-inject; daemon suite 97 incl. new multi-line redaction coverage. 🤖 Built with SMT <smt@agora.build>
🤖 Codex Code ReviewFindings
No other security or logic issues stood out in the reviewed diff. |
🤖 Claude Code ReviewSummarySolid, well-reasoned change. The core fix — replacing "last line of stderr" with a loguru-ERROR-preferring summarizer, plus a fail-fast unresolved-placeholder check in the daemon and matching dispatch gates in Core — is correct, and the placement decisions (scan after Findings below. 1. Client hard-blocks runs the server would accept (client/server gate divergence)
const missingSecrets = (runTargets?.referencedSecrets ?? []).filter((s) => !s.present).map((s) => s.name);…and then But So a Related, smaller: on the console page the eval-set config is only included in 2. Re-enabling a schedule silently flips back to disabled
3. Redaction floor leaves short secret values in persisted job errors
Also note redaction is scoped to the aeval-failure path only; the timeout branch and other thrown errors aren't scrubbed. That's fine today (they don't echo config), but it's an easy invariant to break later — worth scrubbing at the point the error is reported to Core instead. 4. Minor
No auth-bypass, injection, or credential-exposure issues found in the new code paths — the brokered-secret withholding invariant is preserved, and the daemon's scan placement relative to |
…chedule re-enable Round 3. #1 Client/server gate divergence — my own doing. I narrowed the SERVER gate to the four fields the daemon resolves, but both dialogs kept filtering the full-config `referencedSecrets`, so a placeholder in an unresolved config key permanently disabled Run (no override) while POST /run would have accepted it. run-targets now tags each entry `resolvable`, and the client filters on it — the two agree by construction. #2 Schedule PATCH re-enable had no gate: it 200'd and the next tick silently disabled the schedule again, reason visible only in server logs. Same flap PR #123 closed for pool violations; now returns the 400 with the names. #4 nit: the missingSecretNames doc block had re-attached itself to resolvableSecretSources during the earlier reorder. Test updated for the additive `resolvable` field (an exact-match assertion, correctly failing on a real shape change). New test: re-enabling a schedule whose secret is missing 400s instead of flapping. Deferred with reasons: ${config.*} indirection can still smuggle a secret ref past the gate (falls through to the daemon's now-clear error, and widening the scan is what risked disabling schedules); 8-char redaction floor; one extra secrets read per due schedule per tick. 435 passing across tier-pool-dispatch, session-dispatch, api.test, eval-agent-daemon, session-inject. 🤖 Built with SMT <smt@agora.build>
🤖 Codex Code ReviewFindings
Notes
|
🤖 Claude Code ReviewOverall this is a well-constructed change — the daemon-side scan is correctly ordered after session injection (and that invariant is pinned by a test), the server gates sit ahead of 1. Every other call site passes both configs ( 2. The floor is a reasonable instinct (a value of Related, and pre-existing but worth knowing given this PR's framing: 3.
4. Both the gate and the daemon scan are framework-blind, which can hard-block workflows that run today aeval never consumes 5. Nit — redundant secret fetch on the scheduler tick
|
…ces; lower redaction floor Round 4. #1 The re-enable gate I added last round passed only [wf.config], omitting the eval-set config — so a schedule whose EVAL SET holds the missing secret still flapped, which is exactly the bug the gate exists to close. The scheduler checks both; now so does PATCH. #4 Both the gate and the daemon scan were framework-blind. aeval never reads `app`; voice-agent-tester never reads stepsPrefix/stepsSuffix. A stale placeholder in a field the selected framework ignores ran fine before and would have started hard-blocking. Both sides now pick sources by framework (defaulting to aeval, mirroring the daemon's own fallback). #2 Redaction floor 8 → 4. The failure modes are asymmetric: over-redacting garbles a word, under-redacting leaves a live credential in a persisted, user-visible job error — and short PINs/account IDs are precisely what a high floor leaks. Reasoning recorded in the comment so it stops oscillating. #3 Corrected a false claim in my own comment: the gate and daemon do NOT agree "by construction", because executeJob expands ${config.X} before ${secrets.X}, so a secret reached via config indirection is invisible to both. That direction is fail-safe (run accepted, daemon reports it clearly); widening the scan is what risks disabling schedules, so it stays deferred — now documented rather than misstated. 460 passing across tier-pool-dispatch, session-dispatch, api.test, secrets, eval-agent-daemon, session-inject. 🤖 Built with SMT <smt@agora.build>
🤖 Codex Code ReviewFindings
Notes
|
🤖 Claude Code ReviewReviewed the full diff on the merge ref ( Overall this is careful work. The core direction is right, and two things in particular are correct in ways that are easy to get wrong: placing the daemon's unresolved-placeholder scan after 1.
|
…tersection when unknown Round 5. HIGH (Codex) — resolveSecrets embeds values double-quoted and YAML-ESCAPED, so a secret containing a quote or backslash reaches aeval's output in escaped form and slipped past a raw-value-only scrub, landing in the persisted, user-visible job error. The escape logic is now a named helper and BOTH forms go into the redaction set. Test covers a value with a quote and a backslash. Framework fallback — the server guessed "aeval" when the config omits it, but the daemon actually falls back to its OWN env default (EVAL_FRAMEWORK, a per-agent knob Core cannot see). For a workflow dispatched to an agent running voice-agent-tester, that guess would 400 on stepsPrefix the daemon never reads — and permanently disable the schedule on the next tick, the exact harm this function's docblock says to avoid. Unknown framework now narrows to the INTERSECTION (scenario only), consistent with the module's stated asymmetry: over-blocking is destructive, under-blocking just defers to the daemon's clear error. missingSecretNames returns early when nothing is referenced, skipping a full-scope secrets query on the common path. Deferred with reasons: scheduler disable is permanent on a more transient condition than the cases it mirrors (matches existing precedent; a lastError-style surface is the real fix); v1 scans an eval set whose access check is pre-existing; the agent's own container logs echo raw output unredacted (pre-existing, and a separate boundary from the persisted field). 381 passing across tier-pool-dispatch, session-dispatch, api.test, eval-agent-daemon. 🤖 Built with SMT <smt@agora.build>
🤖 Claude Code ReviewOverall this is a well-constructed fix: the two halves (server-side pre-dispatch gate + daemon fail-fast with a redacted, informative summary) are consistent, the gates are placed before the side-effecting steps ( 1. The gate misses the exact failure shape it was written for when
|
…eys on re-enable alone Round 6. The intersection fallback from round 5 was too conservative in one important case: a workflow that OMITS `framework` with its secret in stepsPrefix — the exact reported job #31006 shape — slipped the gate entirely, so the "don't burn a run / don't flap the schedule" half of the fix never fired for the case that motivated it. (The existing test only passed because it pins framework: "aeval" explicitly.) Safe inference restores it: voice-agent-tester hard-fails without `app`, so a config with no `app` but with steps can only run under aeval — gating those fields cannot reject anything that would otherwise have worked. When `app` IS present and framework is unset, stay conservative. New test covers a workflow with neither `framework` nor `app`. Also: `framework` is workflow-exclusive, so find it rather than merge-spreading every config for one key. And the schedule PATCH gate now keys on wantsEnable ALONE — secret resolution has nothing to do with region or tier, and gating those stopped an owner from repointing an already-broken, already-disabled schedule, i.e. from fixing the thing they came for. Deferred with reasons: the agent's container logs still echo raw aeval output unredacted (pre-existing; a separate boundary from the persisted error, now cheap to close given activeSecretValues); scheduler disable remains permanent, matching existing precedent. 456 passing across tier-pool-dispatch, session-dispatch, api.test, eval-agent-daemon, secrets. 🤖 Built with SMT <smt@agora.build>
🤖 Claude Code ReviewReviewed the full diff against the merge base, plus the surrounding call sites ( OverallThis is careful, well-scoped work. The core fix — replacing "last line of stderr" with an ERROR-line-preferring summarizer, plus a pre-dispatch gate on unconfigured secrets — is correct, and the layering is right: Core rejects up front, the daemon still fails loud post-substitution (catching a secret deleted between dispatch and claim), and the UI mirrors the server gate exactly rather than inventing its own predicate. The scrubbing of decrypted values out of a persisted, user-visible job error is a real security improvement, not just cosmetics. Test coverage matches the claims made in the comments. I verified the two things most likely to be subtly wrong, and both hold:
Findings1. Object.values(jobSecrets).flatMap((v) => [v, VoxEvalAgentDaemon.yamlEscape(v), JSON.stringify(v).slice(1, -1), encodeURIComponent(v)])Worth doing given the output is stored and shown in the console; not a blocker. 2. 3. 4. Duplicated alert block in Nothing here blocks merge. Note I could not run |
…tituted text
Codex medium: the post-substitution regex scan treated a secret whose VALUE
happens to contain "${secrets.X}" as an unresolved placeholder, failing a
perfectly good job. The unsupplied names are now captured BEFORE substitution
(referenced minus supplied), and after session injection we check only whether
those specific names still appear in the strings the selected framework reads.
A substituted value can no longer introduce a phantom "missing" secret, and
brokered refs stripped by injectStorageSession still drop out correctly.
462 passing across eval-agent-daemon, tier-pool-dispatch, session-dispatch,
session-inject, api.test, secrets.
🤖 Built with SMT <smt@agora.build>
🤖 Codex Code ReviewNo findings.
|
🤖 Claude Code ReviewOverall this is careful, well-reasoned work. The core change is sound: gate at every dispatch entry point (run, API v1 run, schedule create, schedule PATCH re-enable, run-now, scheduler tick), fail fast in the daemon, and surface aeval's own diagnosis instead of the PyInstaller banner. The framework-narrowing rationale in A few notes: Incidental bug fix worth calling out. The client filter changed from Redaction has a residual gap (
Neither blocks merge; the second is speculative. Daemon hard-fail is slightly stricter than before for Scheduler disable is silent to the user ( Minor:
Security-wise I found no injection, auth-bypass, or new credential-exposure path: the gates run before |
…ner (#133) * fix(broker): report the real login failure, not aeval's artifacts banner 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> * fix(broker): redact the escaped form of a credential, not just the raw 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> * fix(broker): use escaped credential forms in the defense-in-depth scrub 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> * fix(broker): make scrub order-independent and buffer both aeval streams 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> * fix(broker): bound stream capture, decode UTF-8 across chunks, keep stdout 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> * fix(broker): never cut a captured line mid-credential; narrow the stdout 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> * fix(broker): tighten the stdout quarantine and redact before truncation 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> * fix(broker): redact after classification, not before; select stdout strictly 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> * fix(broker): report captured output on the timeout path; doc + regex tidy-up Four minor review notes on #133. - A broker-level timeout was the one remaining path that told the operator nothing — the same complaint this module exists to fix, one branch over. errCap/outCap were populated and in scope but discarded. Both paths now go through one capturedFailure() helper (flush decoders, summarize, scrub) so they cannot drift, and the timeout appends what aeval had said before it hung: which step it reached, which selector it was waiting on. - summarizeAevalFailure's JSDoc had been orphaned above DIAGNOSIS_LINE by an earlier edit, leaving the two-tier preference order documented on nothing. Moved back onto the function and updated for the artifacts banner and the untrusted-stream caveat. - LOGURU_DIAGNOSIS_LINE required fractional seconds. It fails closed, but a loguru format without .SSS would silently disable the stdout hedge in the one scenario it exists for. Fractional part is now optional. - Documented that selectDiagnosisSource's '' return deliberately covers both "empty" and "rejected", and dropped a stale comment about the summarizer's 4-char floor, which the previous commit replaced with minNeedleLength 0. 🤖 Built with SMT <smt@agora.build> * fix(broker): report only complete lines on timeout; split on every line terminator Two review catches on #133. 1. My timeout-path change created a NEW exposure: it summarized captured output right after SIGTERM, while aeval may be mid-write. createBoundedCapture guarantees the buffer never STARTS mid-line, but it can still END mid-line, and a half-written `password=<secret>` matches no needle and would ride out in the logged, returned, persisted message. The timeout path now reads a new completeText (up to the last newline) and skips the decoder flush, since held bytes are mid-line by definition. The close path is unaffected: the child has exited and stdio is closed. 2. selectDiagnosisSource split on '\n' while the loguru predicate carries /m, whose ^ also anchors after \r, \u2028 and \u2029. A single \n-delimited chunk like "cookie=SESSIONVALUE123\r<loguru ERROR line>" therefore passed both the buffer-level and per-line checks and was retained WHOLE — strip() cannot remove a prefix that isn't at the start of the line — carrying untrusted page text into the persisted error. Splitting on every JS LineTerminator makes "line" mean the same thing to the split and the regex. Both tests verified to fail without their fix. 🤖 Built with SMT <smt@agora.build> * fix(broker): share one definition of "line" between the split and the regexes Review catch on #133: I fixed the split-vs-/m mismatch at the broker boundary but left it inside summarizeAevalFailure, which is the more exposed path — the daemon passes RAW stdout and stderr to it with no quarantine at all, and daemon stdout can echo step params. /m anchors ^ after \r, \u2028 and \u2029, but split('\n') breaks on \n alone, so "leaked=SECRET\r<loguru ERROR line>" counted as ONE line that DIAGNOSIS_LINE matched and strip() could not clean (its prefix pattern is not at index 0), and the untrusted prefix was retained whole. LINE_TERMINATORS moves into aeval-output.ts and is used for that split, so the summarizer, the broker's quarantine and the regexes all mean the same thing by "line". Test added on the daemon path; verified it fails without the fix. Also from the same review: - capturedFailure's comment claimed it was shared by both paths so they could not drift. Untrue since the completeText change — the timeout path inlines its own call. Comment now says which path uses it and why. - Removed an unreachable nl2 === -1 branch: buf ends with '\n' there, so indexOf cannot miss. - The timeout path re-tested the raw buffers for emptiness, which disagreed with describeMintFailure's own gate and could append a redundant ": login failed with no output" for quarantined stdout. It now gates on the returned string via an exported NO_OUTPUT_MESSAGE. - Noted that LOGURU_DIAGNOSIS_LINE is anchored on a leading digit and would stop admitting if a future aeval colorized non-TTY output. It degrades safely, so this is a comment rather than a change. 🤖 Built with SMT <smt@agora.build> * fix(broker): cover URL-encoded credentials, signal-killed exits, CR-split needles Six review catches on #133. 1. credentialForms covered only raw and JSON/YAML-escaped forms, but the whole point of this change is to report aeval's "current URL: <sso login page>" line — and SSO redirects carry the account in a query param (login_hint=a%40b.com), where the raw form never appears. Added encodeURIComponent and its form-encoded variant (+ for space). 2. The close path read .text, justified by "the close path has no such hazard". That holds for a clean exit, but close ALSO fires when the child is killed by a signal (code === null: OOM killer, external SIGKILL, Chromium taking the process down), leaving exactly the half-written line completeText exists to suppress — and outDec.end() would flush an incomplete UTF-8 sequence as U+FFFD, breaking needle matching on that same line. Now prefers complete lines always, falling back to raw text only when there are none AND the exit was clean. 3. [^\S\n] still matches \r, \u2028 and \u2029, so the buffer predicate could match across a line break while no individual line did — silently disabling the stdout hedge and making the "agree by construction" comment untrue. Both regexes now use [ \t]. 4. Redaction needles were still split on '\n' while text is split on LINE_TERMINATORS, so a multi-line secret broken by a bare \r produced fragments the text had split and the needles had not. Now one definition of "line" throughout. 5. Two JSDoc blocks had drifted off their declarations while stacking fixup commits — the load-bearing "stricter than DIAGNOSIS_LINE" rationale and the describeMintFailure pipeline block. Reattached. 6. Noted that createBoundedCapture's dropping mode resumes on '\n' only, which is deliberate and safe (a '\n' is a line boundary under the wider set too), so a future reader does not assume it matches the module's wider definition. 🤖 Built with SMT <smt@agora.build> * fix(broker): stop a lone surrogate in a credential from killing the sidecar Review catch on #133, and the worst bug this PR introduced. encodeURIComponent — which I added last commit for the URL-encoded redaction forms — throws URIError on an unpaired surrogate, and "\ud800" is legal JSON, so such a password arrives from the request body and passes the handler's typeof/non-empty validation. Both credentialForms call sites are inside child-process handlers (the 'close' listener and the timeout callback). The promise executor has already returned by then, so the throw is NOT converted into a rejection: it surfaces as an uncaught exception and takes the whole sidecar down, killing every in-flight mint. Any failing or timing-out mint with such a credential would do it. The encoded forms are now best-effort — on URIError we fall back to the raw and JSON forms, which still redact. Test asserts credentialForms and describeMintFailure both survive a lone surrogate, and it fails with URIError if the guard is removed. Also from the same review: - Emit both hex casings of the percent-encoded form. encodeURIComponent produces uppercase, but the motivating text is a URL echoed back from a target site, which may have written it lowercase. - strip() required whitespace between date and time while LOGURU_DIAGNOSIS_LINE accepts a T, so an ISO-style timestamp would be reported with its prefix un-stripped. Cosmetic, but the two patterns should agree on what a loguru prefix looks like. 🤖 Built with SMT <smt@agora.build> * fix(broker): drop OAuth material from the URL this change surfaces Review catch on #133. The reviewer called it non-blocking; it is cheap and it sits on the exact line this PR exists to surface, so it belongs here. "Error waiting for URL pattern: ..., current URL: <sso page>" is the reported payload, and a mid-flow SSO URL carries material no credential needle can model — an OAuth ?code=, state=, or an implicit-flow #id_token=. Those are neither the email nor the password, so credentialForms cannot reach them, and the message is logged, returned in the 502 body, and persisted by Core as a user-visible job error. stripUrlQueries drops the query and fragment from every URL before the scrub. The diagnostic value of the line is WHICH HOST the browser ended up on — "still on sso2.agora.io" is the entire finding — and that survives intact. Worth noting the ordering this creates: in a URL, an encoded credential is now removed rather than masked, so the percent-encoded needles added in the previous commit still earn their place only for encoded values echoed OUTSIDE a URL (a form body in a Playwright error). The test now asserts both. Also from the same review: - Dockerfile comment still claimed auth-session-broker.ts imports only Node builtins; it also imports ./aeval-output now. - Noted the invisible coupling in the timeout gate: it compares against NO_OUTPUT_MESSAGE, which is safe only because describeMintFailure early-returns that constant before any scrub can touch it. 🤖 Built with SMT <smt@agora.build> * docs(broker): fix comment/code drift and complete the esbuild external list Two nits from the #133 review, both the "comment asserts what the code does not" class this PR kept running into. - The strip() comment described a `\S+[ T]?\S*` pattern; the code is `\S+(?:\s+\S+)?`. Rewritten to explain the actual mechanism: with an ISO timestamp the date and time are ONE token, so the mandatory second token matched nothing. - string_decoder was imported but absent from the Dockerfile's `--external:` list. It works either way (esbuild auto-externalizes builtins under --platform=node), but the list reads as authoritative, so an incomplete enumeration is worse than none. 🤖 Built with SMT <smt@agora.build> * fix(broker): remove quadratic backtracking; keep the last line on a clean exit Three minor review catches on #133. 1. DIAGNOSIS_LINE's leading [^\n]* let a \r-heavy buffer with no '|' consume to the end and backtrack from each of ~32K /m anchor positions — quadratic, event-loop-blocking CPU in a single-threaded sidecar, on the failure path of a request an operator is waiting on. Excluding every line terminator from the class fixes it and, better, makes "whole-buffer matches iff some split line matches" structural instead of argued. 2. `completeText || (cleanExit ? text : '')` kept .text only when there were NO complete lines, so a clean non-zero exit whose output lacked a trailing newline silently dropped its last line — the one most likely to hold the diagnosis. On a clean exit the stream is finished, so an unterminated tail is a whole line; only a signal-killed child needs it dropped. Now `cleanExit ? text : completeText`, which is what the comment always said. 3. The esbuild --external list named string_decoder but not crypto, and the build worked either way, because --platform=node already externalizes every builtin. My previous commit calling that list "complete" was the wrong direction: a partial enumeration reads as authoritative and invites someone to diagnose a future missing builtin as a build break. Builtin flags dropped; @aws-sdk/client-s3 stays, since it is not a builtin. Verified with a --no-cache-filter rebuild: identical 12.6 kb bundle, still no node_modules. 🤖 Built with SMT <smt@agora.build> * fix(broker): make bounded capture line-based so a blob can't evict the diagnosis Review catch on #133: my earlier fix only covered the case where the overlong line had NO terminator. If it ends with a newline, indexOf('\n', cut) finds that terminator and the slice drops everything before it — so "ERROR | real cause\n" + "X".repeat(100_000) + "\n" again reported "login failed with no output", which is the exact failure this module exists to remove. Verified against a simulation of the old implementation: it retains '' for that input. The slice-based design had the wrong shape for the invariant. It now tracks retained whole lines and the line currently being written separately: - an overlong LINE is dropped on its own, rather than forcing eviction of everything captured before it - eviction, when the budget is genuinely exceeded, removes whole lines from the FRONT, so the retained text never begins mid-line — the redaction property, since scrubCredentials matches whole credential forms and text starting inside `password=<secret>` leaves an unmatchable suffix - an abandoned overlong line keeps being abandoned until its terminator, so the next chunk's continuation cannot return as if it were a fresh line - text stays within the limit, where the previous shape could hold complete lines plus an unterminated tail 🤖 Built with SMT <smt@agora.build> * chore(broker): unexport an unused constant; document strip()'s wider reach Two non-blocking review notes on #133. - DEFAULT_MIN_NEEDLE_LENGTH was exported and imported nowhere (the broker passes the literal 0). Module-private now. - strip()'s loosened prefix pattern also strips a whitespace-free prefix like "cookie=abc|ERROR|x" down to "x" — wider than the T-timestamp spelling it was aimed at. Deliberate and worth keeping (such a line only arrives from an untrusted stream, and dropping the prefix is the safer direction), so the comment now says so rather than understating it. 🤖 Built with SMT <smt@agora.build> * fix(broker): strip URL queries before truncation, not after Four review catches on #133. 1. stripUrlQueries ran AFTER summarizeAevalFailure's 500-char truncation. A real SSO redirect with redirectUri/state/PKCE runs 300-800 chars, so the query consumed the budget, truncated away the SECOND ERROR line — the actual "Step 1 failed" diagnosis — and only then deleted the material that had displaced it. The result was a shorter message MISSING the diagnosis, which is the failure this PR exists to fix, reintroduced by its own mitigation. The unit test passed only because its fixture query was "...". Stripping the inputs is safe here in a way pre-SCRUBBING is not: it rewrites only text after `https?://...[?#]`, and a loguru timestamp/level prefix never lives inside a URL, so classification is untouched. It also shortens the window in which a ?code= is present at all. 2. createBoundedCapture split on '\n' while the rest of the pipeline uses LINE_TERMINATORS. Not a leak — eviction and the overlong-line drop both cut at a terminator either way — but a writer ending lines with a bare \r (Chromium/Playwright progress output inheriting the child's stdio) would accumulate into `partial` until it tripped the overlong-line guard and got discarded wholesale, swallowing a diagnosis. One definition of "line" now. 3. Documented the trust boundary in the module header: stdout is quarantined and the login pair is scrubbed in every encoding we model, but stderr is admitted whenever it carries a diagnosis, so Playwright errors quoting page state (a DOM snapshot, an <input value="..."> holding a CSRF token or hidden id_token) can reach the persisted job error. Modelled by no needle, not a URL. Exposure is to the job's own owner; tracked on #138. 4. Dropped the decorative builtin --external flags from the daemon stage too, for the same reason as the broker stage. Both images rebuilt and checked. Both new tests verified to fail against the previous behaviour. 🤖 Built with SMT <smt@agora.build> * fix(broker): reduce URLs to scheme+authority; trim before admission Four minor review catches on #133. 1. stripUrlQueries cut only at ? or #, but SSO and magic-link flows routinely carry the sensitive material in the PATH — /oauth2/callback/<jwt>, /reset/<token>, /auth/verify/<nonce> — the same class as a query ?code= and equally unmodellable by credentialForms. Since the docstring's own claim is that the diagnostic value is WHICH HOST the browser ended up on, the path costs nothing to drop. 2. Same regex change bounds a lazy-scan hazard: [^\s"'<>]*? with no [?#] later in the run made the engine rescan to end-of-run from every http:// start, over attacker-influenceable text, twice per failure. Excluding / from the authority class caps it. 3. selectDiagnosisSource tested untrimmed lines while LOGURU_DIAGNOSIS_LINE is anchored at ^\d{4} and summarizeAevalFailure trims before testing — so a loguru line arriving with a leading space was dropped by the hedge though the summarizer would have accepted it. That is the same predicate disagreement this module spends its length eliminating. 4. The header still claimed "nothing is persisted or logged", contradicted by the console.error of a summarized message. Reconciled, and it now points at the boundary note for what that message can contain. Also added a test pinning the NO_OUTPUT_MESSAGE early return ahead of any scrub, since the timeout gate's string equality depends on it and only a comment enforced it. 🤖 Built with SMT <smt@agora.build> * fix(broker): drop URL userinfo; amortize eviction; cut iteration archaeology Four review catches on #133. 1. The URL reduction kept userinfo — https://user:s3cret@host/cb?code=... became https://user:s3cret@host/… — and a URL with userinfo and NO path or query did not match at all, so it was retained whole. Embedded basic-auth is exactly the material-no-needle-can-model class this exists to remove. The path segment is optional now and userinfo is dropped, so every URL reduces to scheme + host. Renamed stripUrlQueries -> reduceUrlsToHost, since it has not only stripped queries for two commits. 2. evictOldest ran on every completed line once `complete` was full, and both the exec and the slice flatten a 64 KiB ConsString — so a child emitting megabytes of short lines (Chromium debug spew; /mint drives a browser) cost gigabytes of memcpy. It now runs to 2x the limit before trimming back to 1x, making eviction amortized O(1). Retained text is bounded by 2*limit rather than limit, which is still bounded. 3. Several comments explained why an earlier form "was wrong" or described a "previous" regex — states that only ever existed between commits on this branch, so a future reader would search for code that is not there. Cut, keeping the invariant statements (the redaction/line-alignment coupling, the trust boundary) that earn their length. 4. The daemon asymmetry is named on #138, with a table of which of these defences it has and has not received. 🤖 Built with SMT <smt@agora.build> * fix(session): actually deliver the broker's diagnosis, to the right party Review catch on #133, and the one that mattered most: everything this PR does to produce a good failure message stopped at the sidecar's own container log. mintViaBroker discarded the 502 body and threw `broker mint failed: ${status}`. That string is what ensureSession writes to webSessions.lastError, which is what Core logs and what the eval-agent session endpoint returns — so end to end an operator still saw "broker mint failed: 502", never "Step 1 failed: platform.setup". The PR title was not true of the system, only of the sidecar. mintViaBroker now folds the body's `error` into the thrown message, capped at 500 chars since it is third-party text landing on a durable field. Propagating it raises a disclosure question the same review flagged: the eval-agent 503 body goes to the CLAIMING AGENT, and on a consented attested-shared dispatch that is a marketplace agent — precisely the party the broker exists to keep away from login-adjacent material. A mint error can quote page state, so passing it straight through would partly undo that. isSessionServable's first two arms (owner, same-org) are therefore split out as isOwnerOperatedAgent, and the 503 serves detail only to those. Its third arm — a consented attested shared agent — still receives the storageState but gets the status alone. Serving the session and explaining why minting it failed are different disclosures. The full text stays on the session row and in Core's log either way. Also: reduceUrlsToHost's userinfo group excluded '@' while the host class did not, so http://user:p%40ss@host/x reduced to http://p%40ss@host/… — the userinfo remainder surviving into a logged, returned message. Greedy over '@' now, which cannot change the no-userinfo case since the host class has no '@'. 🤖 Built with SMT <smt@agora.build> * fix: share the URL/encoding defences with the daemon; bound the mint call Five review catches on #133. 1. The daemon got this PR's tests but not its fix. It passes RAW stdout to summarizeAevalFailure with no quarantine and no URL reduction, and its activeSecretValues was only [raw, yamlEscape] — so a secret in a ?login_hint=, an OAuth ?code=, or a magic-link path token still landed verbatim in the persisted job error, on the path with MORE exposure than the broker. urlForms and reduceUrlsToHost move into aeval-output.ts next to the summarizer, both consumers import them, and the daemon now reduces URLs before summarizing and includes the URL encodings in its needles. The daemon keeps yamlEscape rather than switching to the broker's JSON spelling, since that is what resolveSecrets actually substitutes. 2. reduceUrlsToHost matched only http(s). On the daemon path a LiveKit/Agora signaling URL with ?access_token=<JWT> is routine in an aeval error, so it covers ws/wss too. 3. URL reduction ran before credential scrubbing, so a credential whose VALUE is a URL (a reset link, a webhook secret) was rewritten into something no needle matched, leaking its host. The URL-shaped needles are now redacted first. Pre-scrubbing is safe for this subset specifically — a needle containing "://" cannot occur inside a loguru timestamp/level prefix, so classification is untouched, which is the reason pre-scrubbing with ALL needles is avoided. 4. mintViaBroker had no AbortSignal, while auth-session.ts's staleMintThresholdSeconds() is derived from a comment asserting one exists. A hung broker left the promise pending forever, the row stuck in 'minting' until stale-reclaim, and ensureSession's catch never fired. Added, matching the documented mintTimeoutSeconds() + 15s. 5. The 503 fallback used ?? so an empty-string lastError passed through as "". Full local gate green: 1644/1644 across 88 files. 🤖 Built with SMT <smt@agora.build> * fix: share the URL-valued-secret guard; re-redact Core-side; bound the scan Five review catches on #133. 1. The daemon applied reduceUrlsToHost with no equivalent of the broker's URL-shaped-needle pre-scrub. Secrets that ARE URLs are routine on that path — a LiveKit wss://<project>.livekit.cloud server URL, a webhook endpoint — and reducing one destroys the needle that would have redacted it, leaving the host in the persisted job error. The guard is now reduceUrlsSafely() in aeval-output.ts, used by both, so the asymmetry cannot return. 2. createBoundedCapture.push re-sliced the chunk per line, making it O(lines x chunk): a 64 KiB read of 80-char lines meant ~800 iterations each copying ~32 KiB — the same memcpy volume the eviction hysteresis exists to remove, reintroduced one level up. Now a sticky scan over a position index. 3. A non-numeric WEB_SESSION_MINT_TIMEOUT_SECONDS made parseInt return NaN, and AbortSignal.timeout takes [EnforceRange] unsigned long long — so it threw synchronously and took the whole mint path down, worse than the skewed staleMintThresholdSeconds() a malformed value used to cause. Zero and negative would abort instantly. All three fall back to the default now. 4. Core folded the broker's error body in verbatim, trusting the broker's scrub, then persisted it to webSessions.lastError. Core holds the plaintext pair, so it now re-redacts with its own copies (longest first, so a password containing the email can't be shredded into an unmatchable remainder). 5. The disclosure boundary had no test. isOwnerOperatedAgent is now asserted directly, including the case that matters: for a consented attested marketplace agent, isSessionServable is true (it may have the storageState) while isOwnerOperatedAgent is false (it may not have the failure detail). A refactor collapsing the two would now fail rather than ship page-state detail to marketplace agents on a green suite. Full local gate: 1650/1651, the one failure being agent-observed-ip contending with the live daemon's heartbeat (passes in isolation). 🤖 Built with SMT <smt@agora.build> * fix: one credential-redaction definition for Core, daemon and broker 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> * fix: redact before truncating in Core; one validated mint-timeout helper 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> * fix(daemon): never derive metrics from a truncated log; report timeout 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> * fix: one clamped mint timeout for all three readers; stop evicting for 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> * fix(daemon): check every on-disk metrics source; raise the capture cap 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> * fix(daemon): gate the truncation guard on what was parsed, not what exists 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> * fix: make the mint deadline ordering structural, not documented 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>
The problem
A workflow referencing an unconfigured
${secrets.X}produced this on the job record:A Python packaging artifact. Meanwhile the agent's own log said exactly what was wrong:
Reported from prod job
#31006(a cloned workflow whose secrets didn't come with it). The truth existed at four points and each one dropped it:referencedSecretswithpresent: falseand ignored it; server and client both only ever filtered onpresent === true.resolveSecretsloggedSecret placeholder ${secrets.X} not foundto the agent's local console and returned the placeholder verbatim. That line never reaches the job or the UI.${secrets.X}(hence "Unknown variable source", not "secret missing"), aborted, exit 1.stderr.split('\n').pop(). For a PyInstaller binary the banner is printed after the traceback, so the one line retained was structurally guaranteed to be the least informative.The fix
A — reject at dispatch, naming the secrets. New
missingSecretNames()helper, enforced at all four job-minting entry points: run route, v1 run, schedule create, schedule run-now. Scope is the workflow owner's, matching what the job-secrets endpoint resolves at claim time. Schedules are included deliberately — otherwise they emit a doomed job every tick.B — daemon fails fast on an unresolved placeholder, before aeval is spawned. Deliberately a post-substitution scan rather than instrumentation inside
resolveSecrets, because substitution is skipped entirely when the secrets map is empty (if (Object.keys(jobSecrets).length > 0)) — exactly the reported case. Also catches a secret deleted between dispatch and claim, which A can't.C —
summarizeAevalFailure()replaces the last-line heuristic: prefers aeval's own loguruERRORlines (scanning both streams, since its sink may be either), strips thetimestamp | LEVEL |prefix, falls back to a noise-filtered tail, and caps length so one bad run can't bloat the error column. Lives inshared/so tests can import it without pulling in the daemon entrypoint (which runsmain()and would callprocess.exit).D — client shows it and disables Start. This also fixes a latent bug the work uncovered: both dialogs typed
referencedSecretswithclass: "runtime" | "protected"while the server sendsbrokerType(renamed when the class split became the broker-type column). Sos.class === "runtime"wasundefined === "runtime"— always false. Consequence: the runtime-secret exposure warning and its consent checkbox have never rendered, andruntimeSecretConsentwas never sent — making a shared-tier dispatch of any workflow with runtime secrets unsatisfiable from the UI, since the server hard-requires that flag. Latent today only because the marketplace plugin isn't live.Known behavior change
collectSecretRefsJSON-stringifies the entire config, while the daemon only substitutes intoscenario/app/stepsPrefix/stepsSuffix. A stray${secrets.X}in a non-substituted field will now block a run that previously succeeded. Accepted: the same superset already gates the existing Brokered-misuse 400, and the message stays actionable either way.Test plan
stepsPrefix(i.e.platform.setup), which is where #31006's was.summarizeAevalFailure: recoversUnknown variable source: secretsfrom the reported log verbatim, never returns thepyi_entrypointbanner, works when the ERROR lines are on either stream, falls back to a filtered tail, never returns empty, and respects the length cap. These would fail against the old heuristic by construction.npm run checkclean; 455 passing across tier-pool-dispatch, eval-agent-daemon, session-dispatch, api.test, secrets, secrets-class-api.Not covered (deliberately)
The origin of the confusion — cloning copies
${secrets.X}references but not values, since secrets are per-owner — is untouched. Surfacing required-vs-missing secrets on the workflow itself (badge, or a note at clone time) is the higher-leverage UX fix and belongs in its own change. Separately, C is a heuristic over log text; the durable answer is aeval emitting a structured failure reason, which is an upstream ask.Generated with SMT smt@agora.io