Skip to content

fix(cli): surface cloud-exec stream failures instead of silent success - #804

Merged
DorianZheng merged 1 commit into
mainfrom
fix/cloud-exec-error-surfacing
Jun 16, 2026
Merged

DorianZheng merged 1 commit into
mainfrom
fix/cloud-exec-error-surfacing

Conversation

@DorianZheng

@DorianZheng DorianZheng commented Jun 16, 2026 •

Copy link
Copy Markdown
Member

What

Cloud exec streams stdout/stderr over a WebSocket. When that attach fails
(e.g. a 401 on the upgrade), the client recovered the exit code via a status
probe and reported a silent exit 0 with no output — indistinguishable from a
command that genuinely produced none.

Changes

  • emit_or_fallback now carries the disconnect cause in ExecResult.error_message
    (was None); it is only reached when the output stream failed.
  • StreamManager::start() surfaces a non-empty error_message as an error
    (exit 1 + message) instead of dropping it.
  • REST 401/403 mapping reads unauthorized (HTTP 401) / forbidden (HTTP 403)
    instead of a bare auth: <body>.
  • Adds debug!/trace! to the previously-uninstrumented WS attach path
    (connect, per-frame, stdout/stderr, exit-frame, status-probe fallback).

Verification

  • Real cloud exec: before = silent exit 0, empty; after = exit 1 + clear cause.
  • Unit reproducer terminal::tests::stream_manager_surfaces_exec_error_message_as_error verified RED→GREEN.
  • 4 WS attach unit tests still pass.

Summary by CodeRabbit

  • Bug Fixes

    • Distinguished error messages for authentication (HTTP 401) versus authorization (HTTP 403) failures
    • Improved error reporting when process execution streaming is interrupted
  • Chores

    • Enhanced diagnostic logging for WebSocket connection handling

Cloud `exec` streams stdout/stderr over a WebSocket; when that attach fails
(e.g. a 401 on the upgrade) the client recovered the exit code via a status
probe and reported a silent `exit 0` with no output — indistinguishable from a
command that genuinely produced none.

- `emit_or_fallback` now carries the disconnect cause in
  `ExecResult.error_message` (was `None`); it is only reached when the output
  stream failed, so the result is no longer reported as a clean exit.
- `StreamManager::start()` surfaces a non-empty `error_message` as an error
  (exit 1 + message) instead of dropping it.
- REST 401/403 mapping now reads `unauthorized (HTTP 401)` / `forbidden
  (HTTP 403)` rather than a bare `auth: <body>`.
- Add `debug!`/`trace!` to the previously-uninstrumented WS attach path
  (connect, per-frame, stdout/stderr, exit-frame, status-probe fallback).

Reproducer: terminal::tests::stream_manager_surfaces_exec_error_message_as_error
@DorianZheng
DorianZheng requested a review from a team June 16, 2026 11:11
@coderabbitai

coderabbitai Bot commented Jun 16, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 34f65b26-5909-4f38-83d5-965e24df9926

📥 Commits

Reviewing files that changed from the base of the PR and between 075f6a6 and 6558c6c.

📒 Files selected for processing (3)
  • src/boxlite/src/rest/error.rs
  • src/boxlite/src/rest/litebox.rs
  • src/cli/src/terminal/mod.rs

📝 Walkthrough

Walkthrough

map_http_status gains separate 401 and 403 error messages. In litebox.rs, emit_or_fallback now sets ExecResult.error_message = Some(cause) (previously None) when the status probe recovers a terminal exit code after a WS disconnect; additional tracing is added throughout the WS pump. StreamManager::start in the CLI then checks error_message post-loop and returns Err via anyhow::bail!, with a regression test validating this path.

Changes

WS Disconnect Error Propagation

Layer / File(s) Summary
Specific 401/403 auth error messages
src/boxlite/src/rest/error.rs
map_http_status splits the combined 401 | 403 arm into two separate arms with distinct messages that include the HTTP status code (unauthorized (HTTP 401) vs forbidden (HTTP 403)).
WS fallback: emit cause in ExecResult and tracing
src/boxlite/src/rest/litebox.rs
emit_or_fallback changes error_message from None to Some(cause) for terminal-status fallbacks while preserving exit_code. attach_ws and the WS frame pump gain trace!/debug! logs at connect, per-frame receipt, exit-frame, and disconnect-fallback points.
CLI error propagation and regression test
src/cli/src/terminal/mod.rs
StreamManager::start borrows exit_status with as_ref() before the loop exits, then inspects error_message after the loop and calls anyhow::bail! when set. A new async test stubs ExecResult with error_message set, drops I/O channels, and asserts the returned Err contains "401 Unauthorized".

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐇 A status code walked in — was it 401 or 403?
The rabbit split them cleanly, for better clarity!
When websockets drop mid-stream, the cause now travels far,
No silent None to hide the truth of what the errors are.
The CLI now bails with flair, the test confirms it's right —
Hop hop, the error chain is bright! 🌟

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix(cli): surface cloud-exec stream failures instead of silent success' directly and specifically summarizes the main change: preventing cloud exec commands from silently succeeding when WebSocket stream attachment fails.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/cloud-exec-error-surfacing

Comment @coderabbitai help to get the list of available commands and usage tips.

@DorianZheng DorianZheng added bug Something isn't working rust Pull requests that update rust code labels Jun 16, 2026

@G4614 G4614 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

can

@G4614
G4614 enabled auto-merge June 16, 2026 12:38
@DorianZheng
DorianZheng disabled auto-merge June 16, 2026 12:38
@DorianZheng
DorianZheng merged commit d9b36fe into main Jun 16, 2026
36 of 40 checks passed
@DorianZheng
DorianZheng deleted the fix/cloud-exec-error-surfacing branch June 16, 2026 12:39
DorianZheng added a commit that referenced this pull request Jun 16, 2026
… (#809)

## What
The API's WS `/attach` proxy authenticated upgrades **only as an API
key**, so a
browser/OIDC login (Auth0 JWT) — which works for REST — was rejected 401
on the
attach upgrade and cloud `exec` output never streamed. Closes #807. (PR
#804
already made the CLI surface this loudly instead of a silent `exit 0`.)

## Change
`BoxliteWsProxyService.authenticate` now falls through to JWT when the
bearer is
not an API key:
- verify via the existing `JwtStrategy.verifyToken` (same
JWKS/issuer/audience as
  the HTTP path — no hand-rolled verification),
- map `sub`/`uid` like `JwtStrategy.validate`,
- take the org from the URL `{prefix}` tenant (`ATTACH_PATH` now
captures it),
- require org membership — identical to the API-key path.

**Mirrors current behavior exactly** (per scope discussion):
membership-only — no
permission gate (there is no `box:exec` permission and the HTTP
exec/attach routes
enforce none), no rate-limiting, and a JWT with no/`default` tenant is
rejected
(org ambiguous for a multi-org user). `JwtStrategy` is nullable under
`skipConnections`; `authenticate` never throws, so the fire-and-forget
upgrade
caller can't leak a hung socket.

## Verification
- `boxlite-ws-proxy.service.spec.ts`: API-key regression + JWT
member / non-member / cross-org / no-tenant / invalid-token /
skipConnections /
`uid`-claim, plus `matchAttachPath` tenant capture. Verified RED→GREEN
(the JWT
acceptance + membership-check tests fail when the JWT path is neutered).
- Full `nx test api` green (140 tests).

## ⚠️ Security review
Cross-org access boundary. The membership check
`organizationUserService.findOne(urlTenant, userId)` is what stops a JWT
user
attaching to boxes in an org they don't belong to — please scrutinize
that and
the URL-tenant extraction.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Improved WebSocket `/attach` upgrade authentication by adding
tenant-aware routing and validating both API keys and JWTs with correct
precedence.
* Clarified legacy vs tenant-prefixed attach paths and ensured
unauthorized connections are rejected cleanly.

* **Tests**
* Expanded WebSocket authentication tests to cover tenant extraction,
matching/non-matching attach paths, API-key and JWT authorization rules,
claim mapping, and failure cases (including missing/invalid tokens and
missing tenant context).
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working rust Pull requests that update rust code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants