Skip to content

Meta-loop: further harden the auth flow + UX beyond PR #47#53

Merged
dbarrante merged 27 commits into
mainfrom
loop/auth-ux-meta-2026-07-20
Jul 21, 2026
Merged

Meta-loop: further harden the auth flow + UX beyond PR #47#53
dbarrante merged 27 commits into
mainfrom
loop/auth-ux-meta-2026-07-20

Conversation

@dbarrante

Copy link
Copy Markdown
Collaborator

Summary

Planned follow-up from #47 ("run a meta-loop over this branch to further optimize the auth flow + UX, not just patch the reported bug"). Ran an automated multi-round audit-fix-test loop (LOOP-01 backend/API lens + LOOP-06 UX lens, adapted for a terminal CLI — no browser/Playwright applies here) against origin/main (which already includes #47 and the TypeScript 7 migration), then a full independent review pass on top of the loop's own results.

Process: Map → parallel Discover (2 lenses) → adversarial Verify (3 independent skeptics per finding, majority-refute kills it, empty evidence auto-voids a vote) → sequential Implement (one file-group per commit, regression test required) → Test → repeat, capped at 3 rounds. Then two independent cold reviewers (no context from the loop) audited the full resulting diff — one security-focused, one general-quality — and I fixed everything they confirmed before opening this PR.

Results across 3 rounds: 37 raw findings → 34 survived adversarial verification → 23 file-groups implemented. Test count grew 687 → 812 as the loop ran, plus 1 more from my own review-driven fixes: 813/813 passing, clean tsc build, verified via a from-scratch rebuild.

Headline fixes

  • Auth-bypass gap (the reason this loop was worth running): vault.ts's upload/download/delete and vision.ts's media download made their own raw fetch() calls via private-member casts into ApiClient, bypassing Fix: terminal auth 401 on model-select + UX/UI cleanup #47's refresh-on-401 entirely — a token expiring mid-session hard-failed vault/media ops with no recovery. Now routed through two new ApiClient methods (getBinary, postForm) that share the same refresh-on-401, timeout, and same-origin bearer-gating logic as everything else.
  • RFC 8628 device-poll hardening: a network outage during aether auth login's polling could previously read as an ordinary "still waiting for approval" instead of a real problem; a 5xx could false-positive the same way. Now correctly distinguished.
  • Unified error-hint/format conventions: login.ts and authRefresh never adopted the shared formatErrorLine/hint convention Fix: terminal auth 401 on model-select + UX/UI cleanup #47 established elsewhere — a failed login or failed refresh (arguably the two most consequential auth failures) rendered as a raw, hint-less, uncolored line. Now consistent. Terminal-injection sanitization (sanitizeServerText/sanitizeTerm) extended to close the remaining gaps a hostile server response could exploit.
  • ApiClient.request() now timeout-bounded by default (30s), with the long-running generation/streaming call sites explicitly opted into the existing 120s stream-class bound instead — plus StreamIncompleteError/MalformedResponseError so a clean-but-premature stream end or a genuinely malformed 2xx body fails loudly instead of reading as silent success.

Independent review findings (fixed before opening this PR)

Two fresh reviewers with zero prior context audited the full diff cold. Both confirmed the core auth/token/transport hardening is sound and tried hard to break it. Real findings, fixed in 3 follow-up commits:

  • Security: getBinary()'s own fetch() URL-parse failure on a hostile media_url could echo raw attacker bytes (OSC/CSI escape sequences) straight to the terminal — the one gap left after this same loop sanitized every other server-string path. Closed at the source (getBinary) plus the two consumer call sites (media.ts, slash_media.ts).
  • Regression: the new 30s default request timeout was correctly carved out for chat/workflow streaming calls but missed three sibling LLM-generation calls (vision.ts's image/video/storyboard generation, scaffold.ts, port.ts) — any of these that legitimately ran past 30s would now fail where they used to succeed. Fixed.
  • UX composition bug: a picker key-handler fault printed its own diagnostic and a redundant generic "kept current session." line (two messages for one failure) — the two pieces were built in different rounds and never tested together end-to-end. Fixed, with a new end-to-end regression test.

Two review comments were left as documented, non-blocking follow-up (both explicitly not blocking): the "Simplify" phase's error-line consolidation didn't reach every surface (media.ts/slash_media.ts/run.ts still hand-roll a similar-but-not-identical error format — pre-existing scope gap, not a new break), and a pre-existing (unchanged by this branch) edge case where a truly empty 2xx /auth/refresh body still throws a slightly confusing message inside the safe, formatted error path.

Test plan

  • npm run build clean from a from-scratch dist/ rebuild
  • npm test — 813/813 passing
  • Two independent cold-review passes (security + quality) completed; all confirmed findings fixed
  • Your review — this is a much larger diff than Fix: terminal auth 401 on model-select + UX/UI cleanup #47 (27 commits, 47 files, ~3900 lines), so I did not merge it myself; flagging for your own pass before it lands on main

🤖 Generated with automated LOOP-01/LOOP-06 audit loop + independent review, orchestrated via Claude Code.

dbarrante and others added 27 commits July 20, 2026 14:26
…ch()

downloadSpacesFile/uploadFile/deleteSpacesFile (vault.ts) and
downloadMediaFile (vision.ts) issued their own raw fetch() calls via
private-member casts into ApiClient's tokens/baseUrl/authHeaders, so they
never got PR #47's refresh-on-401 retry or its distinct 401/402/403 hint
UX (they threw plain Error, not HttpError, so errorHint()/hintFor()
could never classify them).

Add two public authed methods to ApiClient — getBinary() (streaming GET,
accepts a relative path or an absolute media URL) and postForm() (authed
multipart POST) — both going through the same refresh-on-401 retry and
toHttpError() classification as request()/stream(). deleteSpacesFile now
uses the existing deleteJson(). Delete vault.ts's _bearerToken/_baseUrl/
_authHeaders and vision.ts's authHeaders() cast entirely — no file outside
transport.ts needs to know ApiClient's internal field names anymore.

Also fold downloadSpacesFile's res.arrayBuffer() into downloadFile() and
stream the response body straight to disk instead of buffering the whole
file in memory (mirrors vision.ts's existing pipeline() pattern).

Add test/vault_vision_401.test.ts mirroring auth_401.test.ts's
fetch-stubbing pattern: 401-then-refresh-then-retry for all four
functions, HttpError classification + hintFor 401 wording for
deleteSpacesFile, and a regression check that downloadMediaFile still
fails closed on an insecure (non-loopback http) media host.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…thorization_pending

pollForToken's catch block folded ANY thrown error into authorization_pending,
including raw network failures (ECONNREFUSED, DNS, timeout) with no HTTP body
— an outage during `aether auth login` read as silent normal polling for the
whole expires_in window. Now only an HttpError carrying a real body (the
documented 400 pending/slow_down/expired case) maps to ordinary poll state.
Any other error counts as a network failure; after 3 in a row it prints a
distinct "can't reach the server" warning, and if the deadline is hit while
still failing it throws a distinct timeout message instead of the generic
"login timed out — run `aether auth login` again" (which reads as "too slow").

Adds device.test.ts coverage for: a transient blip that still succeeds with
no warning, a sustained outage that fails with the distinct message, and
genuine authorization_pending polling to deadline with the original message.
…n `aether auth status`

renderAuthBox's /models probe folded a 401/403 (server actively rejected the
stored token — expired or revoked session, the stale-AETHER_TOKEN case PR #47
fixed elsewhere) into the same bare catch used for a genuine network outage.
Either way the panel printed "Authenticated" with Token/API rows, telling the
user they're signed in when the server just said otherwise — `status` had no
error state at all for this case.

Now an HttpError with status 401 or 403 renders a distinct "Session expired"
panel (swapped header/icon, a "sign in again" hint instead of Tier/Default),
title included. Any other failure (no HttpError, or a different status,
including 5xx) keeps the original silent local-only fallback — an outage or
a plan-only 429/etc. is not proof the session itself is dead.

Deliberately unchanged: cmdAuth's `status` exit code stays 0 in the expired
case (the finding asked for a distinct rendered state, not an exit-code
contract — changing that would need renderAuthBox to leak expired-ness out
to its caller, which is out of scope here).

Extends test/auth_401.test.ts (the repo's fetch-stubbing pattern) with four
renderAuthBox cases: 401 -> Session expired, 403 -> Session expired, a real
network throw -> still Authenticated (no regression), and a normal 200 ->
Authenticated with tier/default.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… malformed 2xx bodies

Root fix (transport.ts): ApiClient.request() (backing getJson/postJson/
deleteJson) had no timeout at all, unlike stream()'s 120s default — a
silently-dropped connection to /models, /auth/refresh, or the device-poll
endpoint just hung forever. Now bounded by AETHER_REQUEST_TIMEOUT_MS
(default 30s, mirrors AETHER_STREAM_TIMEOUT_MS; 0 disables it), using the
same raceAgainst/net-controller pattern stream() already uses so a timeout
releases the socket without ever being confused with a caller's own abort.
Also fixed a latent bug this surfaced: raceAgainst's early-return for an
already-aborted signal discarded its `promise` argument without a catch,
which could leak an unhandled rejection — now swallowed, closing the same
gap in stream()'s pre-existing use of raceAgainst too.

Also: a 2xx response whose body fails to parse as JSON used to silently
become `undefined as T`, so callers that trust T (slash.ts's getCatalog ->
`cat.models`, auth.ts's authRefresh -> `r.session_token`) crashed one layer
up with a raw property-access TypeError. request()/postForm() now throw a
new MalformedResponseError (extends HttpError) instead, wired into
errorHint()/hintFor() for a normal, hinted message. A genuinely EMPTY body
(204 No Content, etc.) is unaffected — classified by reading the text first,
not by sniffing JSON.parse's error message, so a body truncated mid-object
by a dropped connection is correctly treated as malformed, not empty.

loginWithPassword() (the headless --username/--password flow, a raw fetch
with no ApiClient behind it) gets its own bounded default via the same env
var, guarded so an explicit 0 doesn't become an instant AbortSignal.timeout(0).

Regression-prevention carve-outs (required by the new default, not scope
creep): a flat 30s would kill any non-streaming call that legitimately runs
long. postJson/getJson/deleteJson gained an optional per-call timeoutMs
override for exactly this. Applied it to: chat.ts's/brain_cloud.ts's/
client.ts's CHAT_PATH non-streaming chat fallback (a full LLM turn), and
bench.ts/test_drive.ts/workflow.ts's project-conversion calls (block
server-side on completed generation/iteration work, not a job handle) —
each now opts into stream()'s own defaultStreamTimeoutMs() instead. Left
orchestrator.ts's delegate/broadcast/gather and audit.ts's exportProof at
the 30s default — those return a job handle or a fast confirmation, not
completed heavy work.

Tests: test/transport_request.test.ts (new — default timeout, per-call
override in both directions, malformed-vs-empty-vs-truncated body
classification, errorHint/hintFor wiring), test/long_running_timeout_overrides.test.ts
(new — bench/test_drive/workflow survive a slow-but-healthy response),
test/chat.test.ts (fallback survives past the metadata default),
test/device.test.ts (a single stalled poll request times out instead of
blocking pollForToken's loop), test/auth.test.ts (loginWithPassword times
out / 0 disables it), test/abort.test.ts + test/slash_abort.test.ts (updated
for request()'s new net-controller wiring — signal identity is no longer
preserved end-to-end, same tradeoff stream() already made; behavioral
equivalence asserted instead).

Note for follow-up: startTestDrive is bounded at the 120s stream default,
but it's an iterate-until-green loop that could plausibly run longer in
practice; prior behavior was fully unbounded. Revisit if real TDD loops
exceed 120s.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…nt.ts and auth.ts

AetherClient's constructor and tokenStoreFromEnv() each hand-maintained their
own copy of the "injected AETHER_TOKEN -> TokenStore" decision, untested
against each other and free to silently diverge. Factor the shared skeleton
into auth.ts's new tokenStoreForInjected(injected, {persistOnLogin}), with the
one axis that legitimately differs (whether an explicit login persists to
disk) passed explicitly at each call site: tokenStoreFromEnv keeps
persistOnLogin:true (PR #47's CLI-entry fix), AetherClient keeps
persistOnLogin:false (a library embed's login() must not clobber the
standalone CLI's on-disk session). Add regression tests pinning both
directions so the two paths can't drift again.
Extract a shared formatErrorLine helper (src/ui/error_line.ts) so a
client-caught error (chat.ts's printError) and a mid-stream server-emitted
error frame (render.ts's Renderer.error) render the identical "✗ msg" glyph,
dim "  ⤷ hint" line, and trailing blank-line separator — the same "session
expired" moment no longer reads differently depending on which path caught
it.
…pe isAbortError

error_hints.ts's header comment implied hintFor and errors.errorHint were
unified beyond 401/402/403/429 (the only statuses httpStatusHint actually
owns). Their 5xx and network-failure wording was always intentionally
separate per-surface (errorHint knows the baseUrl; hintFor's public
signature doesn't), so make that explicit in comments instead of leaving
it looking like an unfixed bug.

Also stop maintaining a second, narrower isAbortError in error_hints.ts
(only checked err.name, unlike errors.isAbortError's cause-wrapped/regex
checks) — it was never imported anywhere in src/, only exercised by its
own test. Re-export errors.isAbortError instead so there's one
implementation to keep correct.
…y/postForm by timeout, dedupe aek_ prefix check

- transport.ts: isCredentialSafeUrl() only checked scheme (any https host
  passes), so getBinary() attached the live session bearer to ANY absolute
  https URL a caller passed -- including a third-party media host completely
  different from the configured Aether API baseUrl. Gate attachment on the
  new isSameOrigin(target, baseUrl) instead; a cross-origin target is now
  fetched unauthenticated rather than leaking the token or failing the whole
  download closed (deliberate, security-relevant behavior change from the
  prior fail-closed InsecureTransportError on non-loopback http targets --
  see the updated tests in vault_vision_401.test.ts for the new contract).

- transport.ts: getBinary()/postForm() had no timeout/AbortSignal.timeout
  bound at all, unlike request()/stream(), reintroducing the "stalled
  connection hangs forever" bug a7b3621 fixed for request(). Both now accept
  a timeoutMs override (default AETHER_REQUEST_TIMEOUT_MS), mirroring
  postJson/getJson/deleteJson. getBinary additionally wraps its body with an
  idle/quiet-period timeout (stream()'s withIdleTimeout, re-wrapped as a Web
  ReadableStream) so a large-but-healthy download isn't killed by a flat
  overall cap -- only a genuinely quiet connection times out.

- core/auth.ts: added the canonical isApiKeyToken() export; transport.ts's
  refreshSession and commands/auth.ts's isApiToken both now delegate to it
  instead of separately hand-checking the "aek_" prefix.

Regression tests: test/transport_getbinary_postform.test.ts (new -- same-
origin gating, cross-origin-no-auth-header, connect timeout, body idle
timeout, large-healthy-download-not-killed, caller-abort-still-wins,
postForm timeout); test/vault_vision_401.test.ts (updated for the new
same-origin contract, cross-origin http/https no-credential-sent cases, and
same-origin-insecure-baseUrl still fails closed); test/auth.test.ts (direct
isApiKeyToken coverage, not just transitively through isApiToken).

npm run build && npm test: 753/753 passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…shows the retry/doctor hint for mid-turn stream timeouts, matching hintFor
… the terminal

loginWithPassword built its thrown Error directly from the server's raw
`reason` field, bypassing the control-char-strip-and-cap step toHttpError
applies to every other server-text path — login.ts's headless catch writes
that message straight to stderr with no sanitization of its own, so a
compromised/misconfigured backend could inject terminal escape sequences
(including OSC 52 clipboard-hijack payloads) via `aether auth login
--username/--password`. The success path had the same gap: `plan`/
`commitment_hash` reached login.ts's stdout write unsanitized too.

Extract toHttpError's strip-and-cap regex into a shared sanitizeServerText()
in transport.ts and apply it to reason/plan/commitment_hash in
loginWithPassword, so any server-supplied text loginWithPassword surfaces
gets the same treatment as every other HTTP-error path.

Adds regression tests in test/auth.test.ts (loginWithPassword-level) and a
new test/login.test.ts (full cmdLogin path, capturing process.stderr/stdout)
asserting a malicious reason/plan cannot survive into the terminal.
… no more hardcoded "are you logged in?"

vault.ts's fail() and media.ts's fail() printed the SAME "are you logged
in?" hint for every failure, even though downloadFile/uploadFile/
deleteSpacesFile/dispatchGeneration etc. already throw a properly-classified
HttpError (401/402/403/5xx). media.ts's mediaGenerate() and all 9 catch
blocks in slash_media.ts printed only the raw err.message with no hint at
all. All of these now route through core/error_hints.ts's hintFor(), the
same fix already applied to workflow.ts's workflowNew, so a vault or media
402/403/5xx/network-outage shows the correct actionable hint instead of a
misleading or missing one.
…adFileSync+Blob double-buffering

uploadFile() read the entire local file into a Buffer (fs.readFileSync)
and then copied it again into an in-memory Blob([data]) before any bytes
went over the wire — an unaddressed resource-consumption gap on the
upload side, even though downloadFile() was fixed to stream straight to
disk in 1d33357. Now hands FormData an fs.openAsBlob() Blob backed by
the open file handle itself, so a large vault upload no longer holds
the whole file in memory twice. Zero-dependency, matches this repo's
no-deps convention.

Deliberately skipped the finding's "at minimum, add a file-size guard"
fallback clause — that was only meant to apply if true streaming wasn't
feasible; since openAsBlob() removes the buffering entirely, an
arbitrary size cap would just reject legitimate large uploads for no
remaining safety reason.

Added a regression test that proves the fix bites: since fs.openAsBlob()
blobs re-validate the file on disk at read time (and a Buffer-backed
Blob copy does not), deleting the source file mid-upload now causes the
Blob read to fail — verified this test fails against the pre-fix code
and passes against the fix.
…ls call

renderAuthBox's first move is a network round-trip to /models with no
cache to fall back on, and cmdAuth wrote nothing to stdout until the
whole call resolved -- up to DEFAULT_REQUEST_TIMEOUT_MS (30s) of silence
on a slow connection, "the REPL just looks hung" (the exact class PR #47
fixed for slash.ts's catalog fetch). Print theme.dim("checking session...")
before awaiting renderAuthBox in both the "status" subcommand and bare
`aether auth` branches, matching the loading-line convention already used
by slash.ts/vault.ts/slash_media.ts.

Adds cmdAuth-level regression tests proving the loading line is written
while /models is still pending (not after), for both call sites.
…te Escape cancel

pickModel's onKey catch silently resolved null on ANY thrown error (render/
decode bug), exactly like a user pressing Escape. showPicker then printed
the same "kept current session." for both, hiding a real internal fault
from the user and from anyone debugging a picker regression.

Now the catch writes a distinct dim diagnostic ("picker error — kept
current session.") before resolving null, so the two paths are visibly
different in the transcript even though both still resolve null and
restore the REPL listeners the same way.
The "<id> is locked on tier <tier>" rejection in confirmSwitch was plain
unstyled text with no next step, unlike every other auth-adjacent surface
where a plan/tier restriction (HTTP 403 via httpStatusHint) gets a dim hint
pointing at /tier or `aether models`. It was also the only line in this
function not wrapped in theme.dim (the restart warning two lines down
already is).

Now the locked-item line is dim and appends the same actionable pointer
httpStatusHint(403) uses, so a tier lock reached via the picker reads the
same as one reached over the wire.

confirmSwitch is exported (alongside the existing resolveSelection) so this
can be unit-tested directly — the module-level catalog cache the /model
tests above populate isn't force-refreshable in-band, so a differently-
available item can't reach this branch through handleSlash alone.
…ncompleteError

runCloudTurn and CloudBrain's pump both let decodeSse's for-await loop exit
cleanly (no throw) when the underlying byte stream just ends without ever
sending a done or error frame — e.g. a proxy/load-balancer that time-boxes
the SSE response and closes the socket inside the idle window. That rendered
whatever partial delta output arrived as a fully successful turn. Both sites
now track whether a terminal frame was actually observed and throw/report
StreamIncompleteError when it wasn't, routed through errorHint/hintFor like
any other failure.
…400 pending shape — a 5xx no longer masquerades as authorization_pending
… every failure

listWorkflows/getWorkflow/deleteWorkflow caught ANY error from listSpaces/
getSpacesContent/deleteSpacesFile -- an expired-session 401, a network
outage, or a 5xx -- and turned it into an empty list / null / false. The
command layer then reported that as legitimate "no data": "No workflows
found in vault.", "workflow not found: <name>", "delete failed -- workflow
may not exist", with zero indication to run `aether auth login`.

vault.ts's own functions never throw for a genuinely empty vault or a
content-less file (200 with `files: []` / `content: null`), so a thrown
error here is always a real failure -- let it propagate to the existing
per-command try/catch that already calls fail().

workflowSave's getWorkflow call sat outside its own try block (the try only
wrapped the later saveWorkflow call), so removing the swallow would have let
a real failure there escape cmdWorkflow entirely and hit main.ts's bare
top-level catch with no hint at all -- gave it its own try/catch too.

Adds test/workflow_vault_error_propagation.test.ts covering list/view/
delete/save against a 401 and a 500, plus a control case confirming a
genuinely empty vault still prints "No workflows found in vault."
…rrorLine

Every error path in cmdLogin (headless --username/--password, device-code
request, and the poll-for-token wait) and authRefresh's failure paths wrote a
raw, unstyled `✗ <message>` straight to stderr — no errTheme color, no
errorHint-derived next step, no /doctor pointer, and none of formatErrorLine's
trailing blank-line separator. Every other auth-adjacent surface (chat.ts's
printError, render.ts's Renderer.error) had already been unified onto that
convention; login/refresh, the surfaces a user hits first, were left bare.

Route every catch in cmdLogin, plus authRefresh's catch AND its 200-with-no-
session_token branch (same bare ✗ line, no err object but the same fix
applies), through formatErrorLine(msg, { hint: errorHint(err, baseUrl) }) —
same convention chat.ts's printError uses, so a network failure during login
or refresh now gets the same /doctor pointer and a rejected session gets the
same re-login pointer as everywhere else.

Adds regression tests in test/login.test.ts (all three cmdLogin catch paths)
and test/auth_401.test.ts (authRefresh's catch + the no-session_token branch)
asserting the printed line carries the ✗ glyph, the errorHint-derived hint
where one applies, and the separator — and confirms a plain error with no
mapped hint (denied-in-browser) still gets styled output with no fabricated
hint.

Deliberately left login.ts's "No token on stdin" (--with-token, empty stdin)
unconverted: it's an exit-2 input-validation message with no underlying err
to derive a hint from, matching the unstyled "usage:"-style convention used
by other exit-2 paths elsewhere in the CLI (config.ts, vault.ts, workflow.ts),
and it wasn't in the finding's cited evidence.
… no more flat 30s cap on large vault uploads

postForm() bounds the ENTIRE request (connect + send-body + wait-for-response)
with one flat timeoutMs, unlike getBinary()/stream() which split into a
connect-phase timeout plus a separate idle-timeout on the body. fetch() gives
no hook to observe outgoing multipart-body progress, so that split isn't
achievable for the upload path. Instead uploadFile() now explicitly passes
defaultStreamTimeoutMs() (120s) to postForm(), the same pattern client.ts/
workflow.ts already use for their long-running CHAT_PATH fallback, instead of
silently inheriting postForm()'s 30s metadata-call default — so a large-but-
healthy vault upload is no longer always killed by RequestTimeoutError just
for taking longer than 30s.
…or hints

errorHint (errors.ts) and hintFor (error_hints.ts) each hand-duplicated four
identical branches (MalformedResponseError, StreamTimeoutError,
StreamIncompleteError, RequestTimeoutError) that don't depend on baseUrl —
different loop rounds added these to both files without reconciling them,
and the RequestTimeoutError wording had already drifted between the two
copies. Extracted the shared branches into errors.ts's new
nonHttpErrorHint(), called first by both errorHint and hintFor; only the
genuinely baseUrl-dependent 5xx/network branches remain separate per file.
812 tests still pass; no test pinned the drifted exact wording.
…error paths

Independent security review of the meta-loop diff found that a hostile
server-controlled media_url can make fetch()'s own URL parser throw a raw
TypeError whose message echoes the target string verbatim — the one path
left unsanitized after this same loop closed the equivalent gap everywhere
else (sanitizeServerText/sanitizeTerm/formatErrorLine).

- transport.ts: getBinary() now sanitizes any non-typed error (a raw
  fetch()-level throw) before it leaves the function, mirroring
  sanitizeServerText's existing treatment of every other server string.
- media.ts: sanitize resp.media_url, resp.text/response, and the catch
  block's err.message before writing them to the terminal.
- slash_media.ts: sanitize writeErr's err.message (shared by every media
  slash command's catch block).
…t the new 30s default

Independent review found a real regression: commit a7b3621 gave
ApiClient.request() a 30s default timeout, and the loop was careful to
carve out defaultStreamTimeoutMs() (120s) overrides for every long-running
generation call it touched (chat.ts, brain_cloud.ts, client.ts, workflow.ts)
— except three sibling LLM-generation calls it missed: vision.ts's
dispatchGeneration/parseStoryboard (image/video/storyboard generation —
aether image, aether video, /photogen, /videogen, /animate, /reframe,
/recut, /storyboard) and the same class in scaffold.ts/port.ts (LLM-backed
code generation and cross-language translation). Any of these that
legitimately ran past 30s now failed with RequestTimeoutError where they
previously succeeded.
… fault

Round 3's fix (9ee863e) had pickModel() print a distinct diagnostic on an
internal key-handler fault, but its caller (slash.ts's showPicker) still
unconditionally printed its own generic "kept current session." right
after — so a real fault showed two back-to-back, redundant lines instead
of one clear message, while a plain Escape correctly showed just one.

pickModel() now resolves undefined (not null) on a fault, distinguishing
it from a deliberate cancel; showPicker checks for undefined and skips its
own message in that case. Added an end-to-end regression test exercising
the full handleSlash -> showPicker -> pickModel path (the prior tests only
covered pickModel in isolation, which is why this composition bug wasn't
caught).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4e4091599c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/core/vault.ts
const dir = path.dirname(outputPath);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(outputPath, Buffer.from(buffer));
await pipeline(res.body as unknown as NodeJS.ReadableStream, fs.createWriteStream(outputPath));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid leaving partial files after failed downloads

When a vault download body errors after some chunks have been received, such as an idle timeout, abort, or mid-transfer network reset, pipeline rejects only after createWriteStream(outputPath) has already written a truncated file at the requested destination. The previous buffered path only called writeFileSync after the full arrayBuffer() succeeded, so failed downloads did not leave a corrupt final file behind. Stream to a temporary file and rename on success, or unlink the destination on failure.

Useful? React with 👍 / 👎.

Comment thread src/core/transport.ts
res = await send();
}
if (!res.ok) throw await toHttpError(res);
return await parseOkBody<T>(res);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the timeout active while reading JSON bodies

If a non-streaming endpoint sends response headers within the timeout and then stalls or trickles an incomplete JSON body, the raceAgainst(fetch(...)) timer has already been cleared and parseOkBody(res) waits on res.text() with no timeout. In that scenario calls like /models or /auth/refresh can still hang indefinitely despite the new request timeout; race the body read as well, or keep aborting net on the same deadline until parsing completes.

Useful? React with 👍 / 👎.

@dbarrante
dbarrante merged commit e07b115 into main Jul 21, 2026
5 of 6 checks passed
@dbarrante
dbarrante deleted the loop/auth-ux-meta-2026-07-20 branch July 21, 2026 14:15
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