Skip to content

fix: surface the real cause of a missing-secret run instead of a PyInstaller banner - #128

Merged
guohai merged 10 commits into
mainfrom
fix/clear-missing-secret-failures
Aug 30, 2026
Merged

fix: surface the real cause of a missing-secret run instead of a PyInstaller banner#128
guohai merged 10 commits into
mainfrom
fix/clear-missing-secret-failures

Conversation

@guohai

@guohai guohai commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator

The problem

A workflow referencing an unconfigured ${secrets.X} produced this on the job record:

aeval exited with code 1: [PYI-3739:ERROR] Failed to execute script 'pyi_entrypoint' due to unhandled exception!

A Python packaging artifact. Meanwhile the agent's own log said exactly what was wrong:

ERROR | Unknown variable source: secrets
ERROR | Step 1 failed: platform.setup - Unknown variable source: secrets

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:

  1. Dispatch — Core computed referencedSecrets with present: false and ignored it; server and client both only ever filtered on present === true.
  2. SubstitutionresolveSecrets logged Secret placeholder ${secrets.X} not found to the agent's local console and returned the placeholder verbatim. That line never reaches the job or the UI.
  3. aeval — received a literal ${secrets.X} (hence "Unknown variable source", not "secret missing"), aborted, exit 1.
  4. Daemon — kept 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 loguru ERROR lines (scanning both streams, since its sink may be either), strips the timestamp | LEVEL | prefix, falls back to a noise-filtered tail, and caps length so one bad run can't bloat the error column. Lives in shared/ so tests can import it without pulling in the daemon entrypoint (which runs main() and would call process.exit).

D — client shows it and disables Start. This also fixes a latent bug the work uncovered: both dialogs typed referencedSecrets with class: "runtime" | "protected" while the server sends brokerType (renamed when the class split became the broker-type column). So s.class === "runtime" was undefined === "runtime" — always false. Consequence: the runtime-secret exposure warning and its consent checkbox have never rendered, and runtimeSecretConsent was 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

collectSecretRefs JSON-stringifies the entire config, while the daemon only substitutes into scenario/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

  • Dispatch gate: run route and schedule create return 400 naming the missing secret, no job created. Fixture mirrors the real failure — the reference lives in the workflow's stepsPrefix (i.e. platform.setup), which is where #31006's was.
  • summarizeAevalFailure: recovers Unknown variable source: secrets from the reported log verbatim, never returns the pyi_entrypoint banner, 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 check clean; 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

…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>
@github-actions

Copy link
Copy Markdown

🤖 Codex Code Review

Findings

  • server/index.ts:431 — The automatic scheduler path still does not re-check missingSecretNames before stamping and creating the scheduled job. Schedule create and “run now” now reject missing secrets, but if a secret is deleted after a recurring schedule is created, processScheduledJobs() will continue minting jobs every tick; the daemon will fail-fast each one, but the PR’s intended “don’t emit doomed jobs every tick” behavior is not achieved. Add the same owner-scope missing-secret check here before stampOwnerSession() / createEvalJob() and disable or skip the schedule with a clear log message.

Notes

  • I didn’t see security regressions around secret values being exposed; the new server/client messages name secret references only.
  • The aeval stderr/stdout summarizer looks reasonable and covered by focused tests.

@github-actions

Copy link
Copy Markdown

🤖 Claude Code Review

Summary

The 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. summarizeAevalFailure is clean and well-tested, and the server-side gates are placed correctly (before job creation / escrow hold, and reusing the already-computed classified in the run route rather than re-querying). One blocker below.

Blocker

vox_eval_agentd/vox-agentd.ts:1874 — the fail-fast check breaks every session-injected (brokered-login) job.

For a workflow that needs a Core-minted session, stepsPrefix must contain params.email: ${secrets.X} / params.password: ${secrets.Y} — that's exactly how parsePlatformSetup (server/auth-session.ts:44) detects the need. Those secrets are brokerType != null, so they are structurally withheld from /jobs/:id/secrets (server/storage.ts:2154, :2622). Core never rewrites the job's stepsPrefix; stampOwnerSession only adds sessionInjection. So after resolveSecrets, those two placeholders survive by design — and the strip that removes them (injectStorageSession, which deletes params.email / params.password) runs at line ~1897, after the new throw at 1874.

Net effect: every auth-broker workflow now fails at claim time with Unresolved secret placeholder(s): LOGIN_EMAIL, LOGIN_PASSWORD ... create it under Console → Secrets — for secrets that exist and are correctly configured. Core's dispatch gate won't catch it either (those rows are present: true), so it's a hard runtime regression on the whole session-injection path.

Fix: move the scan below the session-injection block (after injectStorageSession has stripped the credential params), or when job.config.sessionInjection is set, exclude the snapshot's emailSecret/passwordSecret names from unresolved. Worth an added test: session-injected job with unresolvable login placeholders must still run.

Other findings

Scheduler tick isn't gated (server/index.ts:~396-443). The comment on the schedule-create gate says an unconfigured secret "would emit a doomed job on every tick" — but only creation (routes.ts:2186) and run-now (:2440) check. A schedule created while the secret existed keeps emitting doomed jobs after the secret is deleted, which is precisely the case the run-now re-check was added for. The tick already re-derives canScheduleWorkflow and detectSessionNeed per iteration, so adding missingSecretNames there is consistent (and could auto-disable, like the orphaned-schedule path).

Message is wrong for the public-org-workflow / non-member runner case. missingSecretNames resolves against the workflow-owner scope, but getOrgSecretsForJob fences on the job creator's org membership and deliberately returns {} for a non-member. That run passes the Core gate, then hits the daemon throw claiming the secret is "not configured for the workflow owner" — it is; the runner just isn't entitled to it. Consider softening the daemon-side wording to cover both causes (not configured or not available to this run).

Nit — server/auth-session.ts:258-285: missingSecretNames was inserted between classifyReferencedSecrets's JSDoc and its declaration, so it now carries two stacked doc blocks and classifyReferencedSecrets is left undocumented. Move the "Join referenced secret NAMES..." block back down.

Low — error-string surface. summarizeAevalFailure now stores up to 3 aeval lines (or a traceback tail) in the job's error field, where previously effectively only the PYI banner landed. A YAML/parse error tail can echo a line that already had a secret substituted into it. The blast radius is small (the secrets belong to the workflow owner, and the agent operator sees the raw streams anyway), but if job errors are ever surfaced on a public/community job view it's worth a redaction pass over the resolved secret values before storing.

The client changes (classbrokerType == null rename, missing-secret alert, run-button disable) match the server response shape at routes.ts:4279 and look correct.

…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>
@github-actions

Copy link
Copy Markdown

🤖 Codex Code Review

Findings

  • High: vox_eval_agentd/vox-agentd.ts:1874 runs the unresolved-secret scan before session injection rewrites stepsPrefix. For session-injected workflows, brokered login secrets are intentionally withheld from /jobs/:id/secrets, so ${secrets.EMAIL} / ${secrets.PASSWORD} remain in stepsPrefix until injectStorageSession() strips them at vox_eval_agentd/vox-agentd.ts:1897. This means valid brokered/session workflows now fail before the injection path runs. Move this scan after session injection, or exclude the stamped login placeholders that will be stripped.
  • Medium: server/index.ts:431 still lets the background scheduler mint jobs after a scheduled workflow/eval set starts referencing a missing secret, or after a secret is deleted. The PR adds checks for schedule creation and run-now, but the recurring tick path goes straight from session checks/stamping to createEvalJob at server/index.ts:443. That leaves recurring schedules emitting a failed job every tick; the daemon error is clearer now, but the schedule is not stopped. Add the same missingSecretNames(sessionScopeForWorkflow(workflow), [workflow.config, evalSet.config]) gate here, likely disabling the schedule as with other runtime-invalid schedule conditions.

No other real issues found in the reviewed diff.

@github-actions

Copy link
Copy Markdown

🤖 Claude Code Review

Findings

1. Blocker — the daemon's unresolved-placeholder check breaks every session-injected (brokered login) job

vox_eval_agentd/vox-agentd.ts:1874 runs collectSecretRefs([scenario, app, stepsPrefix, stepsSuffix]) before the session-injection block at :1888.

For a brokered workflow, stepsPrefix legitimately contains platform.setup with email: ${secrets.LOGIN_EMAIL} / password: ${secrets.LOGIN_PASSWORD} — and those secrets are deliberately withheld from /jobs/:id/secrets (server/storage.ts:2154 filter(s => s.brokerType == null), :2622 if (s.brokerType != null) continue). So jobSecrets never contains them, the placeholders survive substitution by design, and injectStorageSession() is what strips those params a few lines later — its own module doc says exactly that ("this transform also strips any email/password params so leftover placeholders can't reach temp YAML or logs").

Net effect: the new check throws for every auth-session job, failJob() records it, and the message is actively wrong — it tells the owner the secret "is not configured" when it is configured and Core intentionally withheld it. Core's dispatch-side gates are unaffected (a brokered secret has present: true), so this only shows up at claim time, after the job is running.

Fix: move the scan below the injectStorageSession rewrite, or exclude the snapshot's sessionInjection login pair from unresolved. Worth a daemon test with a platform.setup + sessionInjection job.

The same conflation hits a second, milder case: an org workflow run by a non-member gets no org secrets by design (getOrgSecretsForJob fences on the creator's membership). That run should still fail, but the message "not configured for the workflow owner" misattributes the cause.

2. The recurring scheduler tick isn't gated

The create-time check (server/routes.ts:2186) and the run-now re-check (:2440, correctly noting secrets can be deleted afterwards) don't cover the actual tick in server/index.ts:~437. A secret deleted after schedule creation → a doomed job emitted every tick, indefinitely. Every other guaranteed-failure condition in that loop (deleted workflow/eval-set, lost schedule authorization, pool violation, split-class pair) disables the schedule; missing secrets should do the same, and it's the loop where "doomed job on every tick" — the create-route comment's own justification — actually applies.

3. Low — summarizeAevalFailure widens what gets persisted into the job error

It now scans stdout as well as stderr and keeps up to 3 lines / 500 chars, versus one stderr line before. aeval's ERROR lines can echo resolved config. That string is stored on the job and visible to the runner, who for a public workflow is not the secret owner. Cheap mitigation: redact the values in jobSecrets from the summary before recording it (the daemon has them in hand at that point).

4. Nits

  • server/routes-api-v1.ts:370 duplicates the error copy instead of reusing MISSING_SECRETS_MSG, and recomputes sessionScopeForWorkflow(workflow) though scope is already bound at :351.

Looks right

  • The client classbrokerType type fix is a real correctness fix, not just typing: s.class === "runtime" never matched the server's response shape, so runtimeExposed was always empty and the shared-tier runtime-secret consent warning was dead client-side (the server-side gate still held).
  • Gate placement in the run route (server/routes.ts:4028) is before the targeting branch and therefore before authorizeDispatch's credit hold — correct, no escrow burned on a rejected run.
  • missingSecretNames resolving against sessionScopeForWorkflow matches both the run-targets endpoint and the claim-time secrets endpoint, so client, dispatch, and execution agree on scope.
  • Splitting aeval-output.ts out of the daemon entrypoint for testability, and the COPY line in the Dockerfile, are both right.

…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>
@github-actions

Copy link
Copy Markdown

🤖 Claude Code Review

Summary

Solid, 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 session-inject.test.ts case pins exactly the invariant that makes the ordering safe), the Core-side gates land before any escrow/authorizeDispatch on the run route, and the aeval-output.ts COPY is in the right Dockerfile stage (daemon, not broker).

Worth calling out: the class: "runtime" | "protected"brokerType client type fix isn't cosmetic. The server's classifyReferencedSecrets has returned brokerType for a while, so s.class was always undefined — meaning runtimeExposed was always empty and showRuntimeWarning (the shared-dispatch runtime-secret consent alert + ackRuntime gate) was dead code on both pages. This PR silently revives it. That's good, but it's an untested behavior change riding along in a "better error message" PR — worth confirming the ack flow still works end-to-end before merge, since nobody has exercised it.

Findings

1. summarizeAevalFailure widens the credential-exposure surface of job error messages (medium)

vox_eval_agentd/aeval-output.ts:29-42 now returns up to 3 ERROR lines (500 chars) scanned across both streams, and this string becomes the job's stored errorMessage. resolveSecrets (vox-agentd.ts:549) substitutes decrypted secret values into the YAML handed to aeval — so any aeval ERROR line that echoes step params/config carries a live credential into a persisted, user-visible field. The old path had the same theoretical risk but in practice never fired (the banner was always last). The daemon has jobSecrets in hand at executeJob; thread the values down and redact them from the summary before fail().

2. "First 3 ERROR lines" can report a transient error instead of the fatal one (medium)

aeval-output.ts:36-39 assumes the first ERROR is the diagnosis and later ones are cascades. That holds for the #31006 trace, but if aeval logs a retried/recoverable failure at ERROR earlier in a longer run, the summary reports that and buries the actual cause — the same failure mode this PR is fixing, mirrored. Consider anchoring to the last error block (or lines after the final Test N marker) rather than the first.

3. The Core gate and the runtime secrets path disagree for public org workflows (medium)

missingSecretNames resolves against sessionScopeForWorkflow(workflow) (org scope), but getOrgSecretsForJob (server/storage.ts:2616-2618) additionally fences on the job creator's org membership. A non-member running a public org workflow passes the new gate (rows exist) and then trips the daemon's scan, which tells them the secret "is not configured for the workflow owner — create it under Console → Secrets." That's actively wrong advice for a case the daemon can't distinguish. Either mirror the membership fence in the gate, or make the daemon message scope-agnostic ("the server did not supply this secret for this job").

4. Brokered-secret misuse produces the same wrong message on non-console paths (low-medium)

findBrokeredMisuse only runs on the console run route. On routes-api-v1.ts and the scheduler, a brokered secret referenced outside platform.setup has present: true (gate passes), is structurally withheld at claim time, and lands on the daemon's new scan with "not configured … create it under Console → Secrets" — for a secret that exists. Lifting findBrokeredMisuse into the shared path alongside missingSecretNames would close this.

5. Scheduler disables permanently on a transient window (low-medium)

server/index.ts:431-445 disables the schedule outright. Secret rotation done as delete-then-recreate has a window where a tick lands in between and silently kills a recurring schedule, with only a log() line — no user-visible reason. It matches the existing misconfigured/pool precedent, so it's defensible, but those conditions aren't self-healing and this one is. Consider a tolerance (skip N ticks) or persisting the disable reason where the user can see it.

6. Copy assumes the runner is the workflow owner (low)

MISSING_SECRETS_MSG ("secret(s) … that you have not configured") and both client alerts ("Create them under Console → Secrets") are wrong when running someone else's public workflow — the scope is the owner's, and the runner cannot fix it. The Run button is now hard-disabled in that case (missingSecrets.length > 0 in the disabled expression), which is a real behavior change for community workflows; make sure the copy matches.

Nits

  • server/auth-session.ts:264-283: missingSecretNames was inserted between classifyReferencedSecrets's doc comment and its declaration. missingSecretNames now carries two stacked doc blocks and classifyReferencedSecrets has none.
  • server/routes-api-v1.ts:370: recomputes sessionScopeForWorkflow(workflow) when scope is already bound a few lines above.
  • missingSecretNames issues its own full-scope secrets read; routes.ts reuses the existing classified (good), but api-v1 / schedule-create / run-now / scheduler each add one redundant read. Small, but easy to pass classified through.
  • vox_eval_agentd/vox-agentd.ts:213-215: stray double blank line.
  • docs/openapi.yaml isn't updated for the new 400 on POST /api/v1/workflows/:id/run.

Test coverage

The 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>
@github-actions

Copy link
Copy Markdown

🤖 Claude Code Review

The change is well-structured: the dispatch gate lands before authorizeDispatch/escrow on the run path, the daemon's post-substitution scan is correctly ordered after session injection (with tests/session-inject.test.ts pinning the invariant that makes that ordering safe), the Dockerfile COPY for the new module is present and esbuild bundles it, and test coverage is genuinely targeted at the reported regression. A few real issues:

1. Multi-line secret values escape redaction (vox_eval_agentd/aeval-output.ts:31-47)

scrub runs on text that has already been split('\n') and trim()ed. A decrypted secret whose value contains a newline — a PEM key, a service-account JSON blob, a multi-line token — is never matched by acc.split(value).join('[redacted]'), because no single line contains the whole value. Fragments of it then land in the job's persisted, user-visible error string. The whole point of scrub is that this string is persisted and rendered in the console.

Scrub before splitting, or normalize each redact value:

const scrub = (text: string) =>
  redact.flatMap((v) => (v ?? '').split('\n'))
    .map((v) => v.trim())
    .reduce((acc, v) => (v.length >= 4 ? acc.split(v).join('[redacted]') : acc), text);

2. activeSecretValues is assigned outside the try whose finally clears it (vox_eval_agentd/vox-agentd.ts:1858)

const jobSecrets = await this.fetchSecrets(job.id);
this.activeSecretValues = Object.values(jobSecrets);   // <- outside
if (...) { scenario = this.resolveSecrets(...); ... }  // can throw
const tempFiles = [];
try { ... } finally { this.activeSecretValues = []; }

If resolveSecrets throws (or anything else between the assignment and the try), decrypted values stay resident on the daemon instance indefinitely — until the next job overwrites them. The comment says "don't retain decrypted values past the job," but the placement doesn't deliver that. Move the assignment inside the try, or wrap from fetchSecrets onward.

3. Server gate over-approximates what the daemon actually resolves

missingSecretNames scans JSON.stringify(workflow.config) + the eval-set config — the entire config object. The daemon only substitutes into scenario, app, stepsPrefix, stepsSuffix. A ${secrets.X} sitting in any other key (a description, a legacy/unused field, a commented-out region of a YAML string) previously ran fine and now:

  • hard-400s the run and schedule-create routes,
  • disables the Run button in both dialogs with no override (unlike the runtime-secret warning, which has an ack checkbox),
  • and most consequentially, permanently disables an existing schedule on the next tick (server/index.ts:432-441), silently — evalSchedules has no lastError column, so the only trace is a server log.

findBrokeredMisuse already uses the same over-approximate collection, so there's precedent, but it only fires on brokered secrets, whereas this fires on any dangling name. Consider narrowing the scan to the four fields the daemon resolves (the daemon's own scan already uses exactly those), which would also make the client gate and the server gate agree by construction.

4. Minor: 4-char redaction floor is low

value.length >= 4 means a secret whose value happens to be 8080, prod, or true will shred every occurrence of that substring in the diagnosis. 8 would be safer with essentially no loss of coverage.

5. Minor: extra full-secrets fetch per due schedule per tick

missingSecretNamesclassifyReferencedSecretsgetSecretsByUserId/getOrgSecrets fetches all rows in scope. The run route correctly reuses its existing classified array (no extra query), but the scheduler tick, schedule-create, run-now and v1 each add one. In the scheduler that's one more round-trip per due schedule per tick, on top of the several detectSessionNeed/stampOwnerSession already does. Not urgent at current scale; worth a shared per-tick cache if schedule counts grow.

Nits (no action needed)

  • summarizeAevalFailure takes the last 3 | ERROR | lines and falls back to the tail; a fatal Python traceback with no loguru prefix loses to an earlier recoverable loguru ERROR. Acknowledged in the comment and an acceptable tradeoff.
  • routes-api-v1.ts:373 uses its own copy ("Create them…") rather than MISSING_SECRETS_MSG; correct there since that route is owner-only, but the divergence is easy to miss if ownership rules loosen.

…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>
@github-actions

Copy link
Copy Markdown

🤖 Codex Code Review

Findings

  • server/auth-session.ts:277 misses secrets introduced through ${config.*} indirection. The daemon expands config vars before resolving secrets (vox_eval_agentd/vox-agentd.ts:1849), so a config like stepsPrefix: "...${config.LOGIN}..." plus LOGIN: "${secrets.MISSING}" will still create a job/schedule because resolvableSecretSources() only scans the raw stepsPrefix/app/scenario strings. The daemon then fails fast at claim time, and recurring schedules can keep emitting doomed jobs. The dispatch gate should evaluate the same effective strings the daemon will resolve, or share that expansion logic.

  • server/routes.ts:4288 returns referencedSecrets for the entire config, while the new client blocker treats every missing entry as fatal (client/src/pages/console-workflow-detail.tsx:140, client/src/pages/run-your-own.tsx:235). Since the server-side run gate was narrowed to only daemon-resolved fields, a missing placeholder in a non-resolved custom/metadata config field can now disable Start in the UI even though the run route would allow and the daemon would not substitute it. The run-targets payload should either expose fatal/resolvable missing refs separately or the client should filter with the same effective-resolvable set as dispatch.

No other security or logic issues stood out in the reviewed diff.

@github-actions

Copy link
Copy Markdown

🤖 Claude Code Review

Summary

Solid, 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 injectStorageSession; narrow the server gate to the four fields the daemon actually resolves) are right and well-documented. Test coverage for the new helper is good, including the redaction and last-error-wins cases. aeval-output.ts is correctly added to the Dockerfile (that's the only build manifest that lists daemon modules).

Findings below.


1. Client hard-blocks runs the server would accept (client/server gate divergence)

client/src/pages/console-workflow-detail.tsx:139 and client/src/pages/run-your-own.tsx:234 compute:

const missingSecrets = (runTargets?.referencedSecrets ?? []).filter((s) => !s.present).map((s) => s.name);

…and then disabled={... || missingSecrets.length > 0 ...}.

But referencedSecrets from /run-targets (server/routes.ts:4288) is built from collectSecretRefs([workflow.config, evalSet.config]) — the full configs. The server-side gate deliberately narrows to resolvableSecretSources() (scenario/app/stepsPrefix/stepsSuffix), for exactly the reason stated in server/auth-session.ts: "Gating on anything wider would reject runs that work today."

So a ${secrets.X} sitting in any other config key (e.g. an unused config.url variant, a notes field) that the daemon never resolves now produces a permanently disabled Run button with no override — while POST /run would happily accept it. Either narrow the endpoint's referencedSecrets (or add a resolvable flag per entry) so the UI filters on the same set, or downgrade the client to a warning rather than a hard disable.

Related, smaller: on the console page the eval-set config is only included in referencedSecrets when evalSetIdRaw is supplied and visible, so an eval-set-only missing secret won't show the banner — the user still gets the 400. Fine, but the banner is then inconsistent with the button's outcome.

2. Re-enabling a schedule silently flips back to disabled

PATCH /api/eval-schedules/:id (enable / re-cron) didn't get the gate that create and run-now got. An owner who re-enables a schedule with a missing secret gets a 200, and the next scheduler tick (server/index.ts:431) disables it again — the reason only lands in server logs. Adding the same missingSecretNames check to the enable path would close the loop cheaply.

3. Redaction floor leaves short secret values in persisted job errors

vox_eval_agentd/aeval-output.ts:44.filter((v) => v.length >= 8). The tradeoff is documented and reasonable, but the consequence is that a user-configured secret shorter than 8 characters is never scrubbed from the job error string that Core persists and renders in the console. A tighter rule (e.g. drop the floor to ~4 and require a word-boundary match, or skip only values that are purely numeric/common words) would keep the "don't shred the message" property without leaving short credentials in the clear.

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

  • server/auth-session.ts:258-275 — two stacked JSDoc blocks: the first (Names of ${secrets.X} placeholders…) documents missingSecretNames but is attached to resolvableSecretSources, and missingSecretNames itself is undocumented. Move it down.
  • server/routes-api-v1.ts:373 uses an inline message ("…not configured. Create them…") rather than MISSING_SECRETS_MSG. That route is owner-only so the wording is accurate, but two copies of the same string will drift; consider exporting the constant with an owner/non-owner variant.
  • The scheduler tick now does one extra secrets query per schedule per tick (on top of the existing detectSessionNeedgetBrokeredSecretNames). Both resolve against the same scope; passing the already-fetched rows through would halve it. Low priority at current scale.

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 injectStorageSession is correct (pinned by the new session-inject.test.ts case).

…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>
@github-actions

Copy link
Copy Markdown

🤖 Codex Code Review

Findings

  • High: vox_eval_agentd/aeval-output.ts:45 leaves short secret values unredacted. Secret values have no minimum length, and this PR now persists selected ERROR lines from both streams into the user-visible job error. If aeval echoes a short password/PIN/token such as hunter2 or a 6-digit code, it will be stored and shown because values under 8 chars are filtered out. This is a credential exposure regression from the new summarizer path; redact all known secret values or avoid returning lines that can contain substituted params.

  • Medium: server/auth-session.ts:267 does not mirror the daemon’s actual resolution order. The daemon first expands ${config.*} inside scenario/app/stepsPrefix/stepsSuffix at vox_eval_agentd/vox-agentd.ts:1851, then resolves/scans secrets. A missing secret hidden behind a config indirection, e.g. stepsPrefix: "${config.loginSetup}" with loginSetup: "${secrets.MISSING}", is resolvable by the daemon but invisible to resolvableSecretSources(). Dispatch/UI will allow the run, and the daemon will still fail it later. The server-side gate should apply the same config interpolation before collecting secret refs, or otherwise include config fields referenced by the resolvable fields.

  • Medium: server/routes.ts:2313 re-checks only wf.config when re-enabling/changing a schedule. If a schedule’s eval-set config contains the missing secret and that secret is deleted after schedule creation, PATCH /api/eval-schedules/:id with isEnabled: true will still return 200, then the scheduler will disable it on the next tick. This undercuts the intended “no silent flap” behavior; include the schedule’s eval set config in this check, like create/run-now/scheduler do.

Notes

  • The main run-route and run-now missing-secret gates otherwise look consistent with the daemon-side fast failure.
  • Tests cover the workflow-config case well, but miss the config-indirection and eval-set re-enable edge cases above.

@github-actions

Copy link
Copy Markdown

🤖 Claude Code Review

Overall 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 authorizeDispatch/escrow so no credit hold is taken on a doomed run, and the classbrokerType client fix quietly revives showRuntimeWarning, which was dead code (s.class was always undefined against the server's payload). A few real issues:

1. server/routes.ts:2313 — the re-enable gate skips the eval-set config, so the flap it's meant to close still happens

Every other call site passes both configs ([workflow.config, evalSet?.config] — create at :2191, run-now at :2454, scheduler tick at server/index.ts:439). The PATCH path passes [wf.config] only. A secret referenced solely from the eval set's config therefore passes re-enable with a 200 and is silently disabled by the scheduler on the next tick — exactly the behavior the comment above it says it fixes. schedule.evalSetId is in scope; fetch it and include evalSet?.config. The new test happens to put GHOST in the workflow config, so it doesn't catch this.

2. vox_eval_agentd/aeval-output.ts:45 — the 8-char redaction floor lets short secret values through into a persisted, user-visible error

The floor is a reasonable instinct (a value of prod would shred unrelated text), but the failure mode is asymmetric: shredding is cosmetic, a leaked credential in evalJobs.error is not. Short passwords/PINs/account IDs under 8 chars pass through verbatim. Consider dropping the floor to ~4 with a word-boundary-aware replace, or keeping the floor only for values matching ^[a-z]+$-style dictionary words.

Related, and pre-existing but worth knowing given this PR's framing: vox-agentd.ts:834 and :839 console.log/console.error the raw aeval stdout/stderr unredacted, so the container logs retain the credential regardless of what scrub does to the persisted string.

3. server/auth-session.ts:265 — the gate and the daemon don't actually "agree by construction"

executeJob resolves ${config.X} placeholders before ${secrets.X} (vox-agentd.ts:1843-1854), splicing any string config key into scenario/app/steps*. So config.url = "${secrets.API_KEY}" referenced as ${config.url} in the scenario is a reference the daemon really does resolve, but resolvableSecretSources never sees it. The result is fail-safe (the run is accepted, and the daemon's new scan produces a clear error rather than the PyInstaller banner), so this isn't a blocker — but the comment's claim is wrong and the UI will report resolvable: false for a secret that genuinely is. Either fold sibling string keys into the sources or soften the comment.

4. Both the gate and the daemon scan are framework-blind, which can hard-block workflows that run today

aeval never consumes config.app; voice-agent-tester never consumes stepsPrefix/stepsSuffix (vox-agentd.ts:1932-1950). A stale ${secrets.X} sitting in a field the selected framework ignores previously ran fine; it now 400s at dispatch and, if it somehow gets past, throws in the daemon. Narrow, but it's a behavior change for existing configs — worth gating the source list on config.framework.

5. Nit — redundant secret fetch on the scheduler tick

missingSecretNames re-runs getSecretsByUserId/getOrgSecrets, which detectSessionNeedgetBrokeredSecretNames fetched moments earlier for the same scope, once per due schedule per tick. The run route already avoids this by filtering the existing classified; the scheduler could thread the rows through the same way.

…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>
@github-actions

Copy link
Copy Markdown

🤖 Codex Code Review

Findings

  • High: vox_eval_agentd/aeval-output.ts:47 redacts only the raw secret values, but the daemon writes YAML-escaped values into the aeval input at vox_eval_agentd/vox-agentd.ts:554. If aeval logs the source/config form, values containing quotes/backslashes/control chars can be persisted unredacted, e.g. secret pa"ssword may appear as pa\"ssword, which the current split(rawValue) scrub misses. Since this field is user-visible, add the exact escaped variants produced by resolveSecrets (and ideally JSON/YAML-rendered forms) to the redaction needles.

  • Medium: vox_eval_agentd/vox-agentd.ts:1918 scans the post-substitution strings for ${secrets.X}, so a valid secret value that literally contains that pattern is treated as an unresolved placeholder and the job fails. Example: ${secrets.MSG} with value ${secrets.NOT_A_REF} becomes "${secrets.NOT_A_REF}" after substitution and is reported as missing. This should track unresolved placeholders from the pre-substitution/config-expanded input and subtract supplied names, rather than regex-scanning arbitrary substituted secret values.

Notes

  • I did not run tests because the environment is read-only with approvals disabled.
  • The server-side dispatch gates and UI typing changes otherwise look aligned with the stated fix.

@github-actions

Copy link
Copy Markdown

🤖 Claude Code Review

Reviewed the full diff on the merge ref (0930b17), plus the surrounding code in vox-agentd.ts, session-inject.ts, auth-session.ts, and the three dispatch paths.

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 injectStorageSession (and pinning that ordering with the new session-inject.test.ts invariant), and scrubbing before the .slice(0, 500) truncation so a cut can't re-expose a fragment. The dispatch gates are all placed after their authz checks. Findings below.

1. resolvableSecretSources assumes aeval when the daemon may not — false-positive blocks and schedule disables

server/auth-session.ts:151

const framework = typeof merged.framework === "string" ? merged.framework : "aeval";

The comment says this "mirrors the daemon's own fallback (config.framework || daemon default aeval)", but the daemon's fallback is config.framework || this.config.framework, where this.config.framework is EVAL_FRAMEWORK || 'aeval' — a documented per-agent env knob (vox_eval_agentd/README.md:163, passed through by scripts/vox-upgrade.sh:203).

For a workflow whose config omits framework, dispatched to an agent running EVAL_FRAMEWORK=voice-agent-tester:

  • the server gates on stepsPrefix/stepsSuffix, which that daemon never reads. A stale ${secrets.X} there now 400s the run — and on the next scheduler tick permanently disables the schedule (server/index.ts:409). That is precisely the harm the function's own docblock says must be avoided.
  • conversely app isn't gated, but that direction is fail-safe (the daemon's own scan catches it).

Given the module's stated asymmetry, the consistent fix is to narrow to the intersection when framework is absent — push only c.scenario — rather than guessing aeval.

2. missingSecretNames does a full secrets query even when nothing is referenced

classifyReferencedSecrets (server/auth-session.ts:317) unconditionally runs getSecretsByUserId/getOrgSecrets before mapping over names. The new callers make this hot: the scheduler now issues one extra secrets query per due schedule, per tick, including for the common case of a workflow with zero ${secrets.*} placeholders. A if (names.size === 0) return [] short-circuit in classifyReferencedSecrets (or in missingSecretNames) removes it entirely.

3. Scheduler disable is permanent, on a much more transient condition than the cases it mirrors

The existing auto-disables (deleted workflow, lost authorization, pool violation) are structural — they can't self-heal. "Secret row is absent" can be a several-second window if a user rotates a credential by delete-then-create. One tick landing in that window silently and permanently disables a recurring schedule, with only a server log as the signal; the user finds out later. Worth either tolerating N consecutive failures before disabling, or at least confirming this is the intended product tradeoff. (The 400 on re-enable is right and helpful — it's the silent disable that's the sharp edge.)

4. v1 route now scans an eval set the caller may not be able to access

server/routes-api-v1.ts:322 fetches evalSet with no canAccessResource check (pre-existing gap — the console route at server/routes.ts:4006 does check). The new gate scans evalSet.config and echoes the referenced secret names back in the 400. That lets an authenticated caller enumerate secret names referenced by another user's private eval set by pointing their own workflow at an arbitrary evalSetId. Names only, not values, so low severity — but adding the same canAccessResource(user, evalSet) guard the console route has would close both this and the underlying hole.

5. Redaction covers the persisted error but not the agent's own logs

summarizeAevalFailure's scrub is sound (longest-needle-first, multi-line split, redact-then-truncate). But vox-agentd.ts:835,841 still console.log/console.error every raw aeval stdout/stderr chunk verbatim — the same credential-bearing lines the new scrub exists to catch, written unredacted to the agent's container logs. Pre-existing and out of this PR's stated scope, but the docblock's framing ("any ERROR line echoing step params can carry a live credential") applies equally there, so it's worth not treating this as solved.

The 4-char floor is a reasonable call and well-argued in the comment.

Minor

  • server/routes.ts:2310 — the comment cites "the same flap PR fix(schedules): consolidate pool gate onto sessionPoolViolation; 403 flapping re-enable #123 closed"; that looks like a placeholder reference rather than a real PR.
  • tests/tier-pool-dispatch.test.tscreatedBy: 1 hardcodes the admin's user id in a direct DB insert. Fine today, brittle if seeding order ever changes; deriving it from the logged-in session would be safer.
  • I wasn't able to run npm run check or the suites here (command approval), so the type-level and runtime verification of the new resolvable field and the daemon import is by inspection only. Per CLAUDE.md, ./scripts/full-tests-run.sh should gate the merge.

…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>
@github-actions

Copy link
Copy Markdown

🤖 Claude Code Review

Overall 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 (stampOwnerSession/ensureSession/marketplace hold) on every path, and the secret-scrubbing on the persisted error is a genuine improvement. A few things worth addressing:

1. The gate misses the exact failure shape it was written for when framework isn't pinned (medium)

resolvableSecretSources (server/auth-session.ts:288-294) only includes stepsPrefix/stepsSuffix when merged.framework === "aeval". But framework is optional — validateWorkflowConfig (server/storage.ts:233) only checks it if defined — and the daemon falls back to config.framework || this.config.framework (vox-agentd.ts:1833), i.e. EVAL_FRAMEWORK || 'aeval'.

So for a workflow that omits framework and puts ${secrets.X} in stepsPrefix — precisely the reported job #31006 shape, and the shape the new test pins only because it explicitly sets framework: "aeval" — the run route accepts, the scheduler queues, and the console Run button stays enabled (resolvable: false). The daemon's new check still produces a good error, but the "don't burn an agent run / don't flap the schedule" half of the fix doesn't fire.

The intersection choice is defensible in general, but there's a stronger signal available here that doesn't over-block: stepsPrefix/stepsSuffix/app are WORKFLOW_ONLY_KEYS and role-disjoint, and voice-agent-tester hard-fails with job.config.app is required when app is absent (vox-agentd.ts:1955). So when framework is unset:

  • app absent + stepsPrefix/stepsSuffix present → the run can only succeed under aeval, so gating on those fields cannot reject anything that would otherwise have worked.
  • app present → keep the current conservative intersection.

That closes the gap without reintroducing the over-blocking risk the comment is guarding against.

2. resolvableSecretSources computes framework from a merge but reads fields per-config

Minor, but the reduce-spread merged is only used for framework, which is workflow-exclusive and can never be set by an eval set (validateEvalSetConfig rejects WORKFLOW_ONLY_KEYS). The merge is dead weight and slightly misleading — configs.find(c => typeof c?.framework === "string") would say what's actually meant.

3. Schedule PATCH gate keys on regionChanged/tierChanged (minor)

server/routes.ts:2307 runs the missing-secret check on wantsEnable || regionChanged || tierChanged. Secret resolution has nothing to do with region or tier, so this 400s a user who only wants to repoint an already-broken (and already disabled) schedule to a different region — they can't fix the thing they came to fix. Conversely cronChanged/capChanged on an enabled broken schedule pass silently. Gating on wantsEnable alone matches the stated intent (close the re-enable flap) without the collateral.

4. Redaction covers the persisted error but not the agent's own logs (note, pre-existing)

summarizeAevalFailure correctly scrubs both the raw and YAML-escaped forms before the string is persisted, and truncates after scrubbing — good ordering. But vox-agentd.ts:838-846 still does console.log('[aeval] ' + data) on every stdout/stderr chunk unscrubbed, so decrypted credentials echoed by aeval land in the agent container's logs verbatim. That's pre-existing and out of this PR's scope, but the new activeSecretValues field makes it a cheap follow-up (scrub in the stream handlers, not just at summary time).

5. Smaller observations

  • strip()'s /^\S+\s+\S+\s*\|\s*\w+\s*\|\s*/ will mangle a non-loguru line that happens to start with two tokens followed by | WORD |. Cosmetic only.
  • The 4-char redaction floor and the asymmetry argument behind it are sound; a 3-char secret leaking is a real but acceptable residual, and the comment says so honestly.
  • activeSecretValues assignment inside the try whose finally clears it is correct — no window where values outlive the job.
  • The session-inject.test.ts addition pins the right invariant (post-injection no ${secrets.*} survives), which is what makes the daemon's scan placement safe. Good call adding it rather than just relying on the comment.

6. Verification note

I could not run npm run check or the test suite in this environment (sandbox blocked the commands), so type-checking and the new tests are unverified here. Per CLAUDE.md, ./scripts/full-tests-run.sh should gate the merge — the new tier-pool-dispatch.test.ts case inserts directly into evalSchedules with createdBy: 1, which assumes the admin user is id 1; worth confirming that holds on a fresh DB.

…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>
@github-actions

Copy link
Copy Markdown

🤖 Claude Code Review

Reviewed the full diff against the merge base, plus the surrounding call sites (executeJob, runAeval, the job-secrets endpoint, classifyReferencedSecrets, the config validators, and the scheduler tick).

Overall

This 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:

  • Scope agreement. sessionScopeForWorkflow (org → org secrets, else owner personal) is byte-for-byte the same branch the job-secrets endpoint takes (server/routes.ts:3833), so the new gate cannot reject a run that would have resolved at claim time. The known under-block (org secrets fenced on the job creator's membership) falls on the fail-safe side and now produces the daemon's clear message.
  • Redaction lifetime. activeSecretValues is cleared in executeJob's finally, and summarizeAevalFailure is called from runAeval's close handler — which is awaited through executeAevalWithChunking before that finally runs. The forced-timeout path can't race it either: proc.on('close') returns early on settled, so the un-redacted branch is unreachable. Scrub-then-slice(0, 500) is also the right order.

Findings

1. vox_eval_agentd/aeval-output.ts:44 — redaction covers only the raw and YAML-escaped forms.
activeSecretValues carries v and yamlEscape(v). That covers the two forms the value actually takes on the way in. But loguru/Python can echo it back re-encoded — a repr() of a dict, a URL-encoded query param, a value nested inside a JSON string. Those survive into the persisted error. This is a mitigation rather than a guarantee, which is fine, but a one-line widening is nearly free:

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. server/auth-session.ts:145 — the hasApp ? null : "aeval" inference disagrees with the daemon when a fleet sets EVAL_FRAMEWORK=voice-agent-tester.
The daemon resolves config.framework || this.config.framework (a per-agent env knob Core can't see). For a config with no framework and no app, Core assumes aeval and gates stepsPrefix/stepsSuffix. On a voice-agent-tester-default fleet that's an over-block — but the run was doomed anyway (job.config.app is required), so the only change is which 400 the user gets, and the new one is more actionable. Correct as written; noting it because the comment argues the inference "cannot reject anything that would otherwise have worked," and that holds only because of the app-required hard-fail, which is a property of a different file. A pointer to that coupling would keep the reasoning checkable if VAT ever gains an app default.

3. server/index.ts:428 — the scheduler's new auto-disable is discoverable only via server logs.
Consistent with the existing misconfigured/pool-violation handling, so no objection to the shape. Combined with the PATCH re-enable gate the loop does close properly: the owner hits PATCH isEnabled:true, gets a 400 naming the exact secrets, fixes it, re-enables. Just flagging that a schedule can now go dark for a reason that leaves no trace on the row itself — if the schedules table ever grows a lastDisabledReason, this is a good first customer.

4. Duplicated alert block in console-workflow-detail.tsx:316 and run-your-own.tsx:609 (identical JSX plus identical missingSecrets derivation). Follows the existing showRuntimeWarning precedent, so it's consistent — but that's now two copies of a gate the comments explicitly say must stay in lockstep with the server. A shared hook/component would make drift impossible rather than merely unlikely.

Nothing here blocks merge. Note I could not run npm run check or the suite (both need approval in this sandbox), so the type-check and ./scripts/full-tests-run.sh pre-merge gate still need to be 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>
@github-actions

Copy link
Copy Markdown

🤖 Codex Code Review

No findings.

  • I reviewed the dispatch gates, secret classification/resolution paths, daemon unresolved-placeholder handling, and failure summarization/redaction logic.
  • I didn’t see security regressions, auth bypasses, credential exposure in persisted errors, or meaningful edge-case/logic issues in the changed code.
  • I did not run the test suite because the environment is read-only, but the added tests cover the main regression paths and the aeval-output heuristic well.

@github-actions

Copy link
Copy Markdown

🤖 Claude Code Review

Overall 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 resolvableSecretSources is correct — framework/app/stepsPrefix/stepsSuffix are all WORKFLOW_ONLY_KEYS, so the inference can't diverge between the run-targets endpoint (which may omit the eval set) and the run route (which always includes it).

A few notes:

Incidental bug fix worth calling out. The client filter changed from s.class === "runtime" to s.brokerType == null. classifyReferencedSecrets returns {name, brokerType, present} and has no class field, so runtimeExposed was always empty client-side — meaning showRuntimeWarning never fired, the ack checkbox never rendered, and any shared-tier run against a workflow with present runtime secrets 400'd at routes.ts:4088 with no UI path to satisfy it. This PR repairs that as a side effect. Fine, but it's a user-visible behavior change (shared-tier runs now require ticking a box that previously never appeared) that isn't in the PR description or covered by a test.

Redaction has a residual gap (vox_eval_agentd/aeval-output.ts:443). activeSecretValues covers the raw and YAML-escaped forms, which is the right pair for what resolveSecrets writes. But two things escape it:

  • The raw aeval stdout/stderr is still console.log/console.error'd unredacted at vox-agentd.ts:839,844, so the container logs carry every echoed credential. The persisted job error is now clean while the log is not — worth noting since the redaction comment implies the value is contained.
  • Session-injected jobs: the minted storageState isn't in the redact list. Only the file path is injected into the YAML, so in practice aeval shouldn't echo cookie values — but if it ever dumps the loaded state on a parse failure, that goes into a persisted, user-visible error.

Neither blocks merge; the second is speculative.

Daemon hard-fail is slightly stricter than before for voice-agent-tester. active = [scenario, app] fails the job on any surviving placeholder anywhere in the app YAML, including a field that framework never reads. The comment justifies the field-level narrowing but not the within-field strictness. In practice the server gate now rejects these at dispatch anyway, so it's consistent — just noting that a previously-working job with a stale placeholder in an unused app key will now fail.

Scheduler disable is silent to the user (server/index.ts:428). Consistent with the existing pool-violation/misconfigured handling, and the PATCH gate closes the flap. But an owner whose secret was deleted gets a disabled schedule with only a server log to explain it — the MISSING_SECRETS_MSG text exists but is never persisted anywhere they can read. Same pre-existing gap as the neighbouring branches, so not a regression; a lastError-style column would close all four at once.

Minor:

  • MISSING_SECRETS_MSG's "otherwise ask its owner to" clause is dead text on the schedule-create and run-now routes, which are owner-only (canScheduleWorkflow). Harmless, just slightly confusing.
  • tests/tier-pool-dispatch.test.ts hardcodes createdBy: 1 and relies on the admin login being user 1 for canEditResource to pass. Fragile if seed ordering ever changes; reading the id off /api/auth/status would be safer.
  • The missing-secrets alert block is duplicated verbatim across console-workflow-detail.tsx and run-your-own.tsx, along with the missingSecrets/runtimeExposed derivation. The file already had this duplication, so it's consistent — but this is the third copy of the same logic and a shared hook would be cheap now.

Security-wise I found no injection, auth-bypass, or new credential-exposure path: the gates run before marketplace.authorizeDispatch (so no escrow is held on a doomed run), the daemon's error message is deliberately scope-agnostic and leaks no names the caller couldn't already see, and the ordering of the unresolved-placeholder scan after injectStorageSession is correct and now pinned by a test.

@guohai
guohai merged commit 3523859 into main Aug 30, 2026
7 checks passed
@guohai
guohai deleted the fix/clear-missing-secret-failures branch August 30, 2026 15:34
guohai added a commit that referenced this pull request Aug 31, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant