Skip to content

fix(screenshot): disable base64 line wrap so STANDARD decoder accepts output#2

Merged
todie merged 1 commit into
mainfrom
fix/screenshot-base64-wrap
Apr 7, 2026
Merged

fix(screenshot): disable base64 line wrap so STANDARD decoder accepts output#2
todie merged 1 commit into
mainfrom
fix/screenshot-base64-wrap

Conversation

@todie

@todie todie commented Apr 7, 2026

Copy link
Copy Markdown
Member

Bug

`reach screenshot ` and the MCP `screenshot` tool both failed on every invocation with:

```
Error: failed to decode screenshot base64

Caused by:
Invalid symbol 10, offset 76.
```

Symbol 10 = `\n`, offset 76 = end of the first MIME line. The fail site is:

```rust
// crates/reach-cli/src/docker.rs:437 (pre-fix)
"scrot -z /tmp/_reach_shot.png && base64 /tmp/_reach_shot.png && rm /tmp/_reach_shot.png"
```

GNU coreutils `base64` defaults to RFC 2045 line wrapping (76 chars per line). The decoder on the Rust side is `base64::engine::general_purpose::STANDARD`, which is strict and rejects any whitespace between symbols. `.trim()` only strips leading/trailing whitespace, not interior newlines.

This broke:

  • `reach screenshot -o …` CLI command (totally unusable)
  • MCP `screenshot` tool routed through `reach connect` and `reach serve`
  • Any workflow that screenshots as a precondition (which is most real agent flows)

Fix

Two changes in `docker.rs::screenshot`:

  1. Pass `-w 0` to the in-container `base64` command. Single char, same effect as `--wrap=0`, disables the default MIME line wrapping. Primary fix.
  2. Strip whitespace on the decoder side before calling `STANDARD.decode`. Defensive only — if a future image ever ships with a non-coreutils `base64` that doesn't accept `-w 0`, the decoder won't crash on minor whitespace.

Verification

On a running Ubuntu 24.04 sandbox (`reach create --name cf-setup --resolution 1920x1080`) before the fix:

```
$ reach screenshot cf-setup -o /tmp/shot.png
Error: failed to decode screenshot base64
Caused by: Invalid symbol 10, offset 76.
```

After:

```
$ reach screenshot cf-setup -o /tmp/shot.png
✓ Screenshot saved to /tmp/shot.png (6136 bytes)
$ file /tmp/shot.png
/tmp/shot.png: PNG image data, 1920 x 1080, 8-bit/color RGB, non-interlaced
```

Built via `cargo build --release -p reach-cli`, installed via `cargo install --path crates/reach-cli --force`, re-ran on the existing cf-setup sandbox, verified valid PNG bytes.

Related

  • Fixes TOD-302.
  • Unblocks TOD-303 (reach sandbox resolution options) and TOD-305 (Playwright chromium SIGTRAP workaround) — both were discovered in the same dogfooding session that hit this bug.
  • Unblocks all MCP agent workflows that depend on `mcp__reach__screenshot`.

Follow-up candidates (not in this PR)

  • Add an e2e test that verifies the screenshot command produces a valid PNG (magic bytes `89 50 4E 47`). The current `make test-integration` tests `docker.screenshot` only up to "exit code 0", which is why this bug slipped through CI.
  • Consider replacing the `scrot + base64 + stdout` pipeline with `docker cp` of a temp file, which would sidestep base64 encoding entirely.

🤖 Generated with Claude Code

@todie
todie force-pushed the fix/screenshot-base64-wrap branch from 6388619 to b1e07ef Compare April 7, 2026 01:29
todie added a commit that referenced this pull request Apr 7, 2026
…4+ schema

Two pre-existing issues on main that were silently breaking CI on every
open PR (#1 release-please, #2 screenshot fix, #3 editorconfig) via the
Format and Deny jobs. Neither was introduced by the open PRs — they
landed in the CI/CD overhaul commit 'ea74fa4 feat: CI/CD overhaul +
process restart loop + zero clippy warnings' (the 'zero clippy warnings'
part was true; the 'cargo fmt --check passes' part apparently wasn't).

## 1. rustfmt violations across 17 files

`cargo fmt --check` on main produces 86 diff hunks across:

  crates/reach-cli/src/commands/{connect,create,destroy,exec,list,screenshot,serve,vnc}.rs
  crates/reach-cli/src/{config,docker,mcp}.rs
  crates/reach-cli/tests/{docker_types,e2e_container,mcp_types}.rs
  crates/reach-supervisor/src/{main,processes,signals}.rs

Applied `cargo fmt --all` with the project's rustfmt.toml (max_width=100).
No behavioral changes — pure whitespace/alignment/import-grouping edits.

## 2. deny.toml schema migration (cargo-deny 0.14+)

cargo-deny 0.14 removed or changed several schema keys:

- `[advisories] vulnerability = "deny"` → REMOVED (cargo-deny now
  always denies vulnerabilities in its pipeline).
- `[advisories] unmaintained = "warn"` → REJECTED. The field's
  semantics changed from severity ("deny"/"warn") to scope
  ("all"/"workspace"/"transitive"/"none"). Migrated to
  "workspace" — we only care about crates we directly depend on being
  marked unmaintained, not transitives we don't control.
- `[licenses] unlicensed = "deny"` → REMOVED (see
  EmbarkStudios/cargo-deny#611 for the migration note). cargo-deny now
  handles unlicensed crates as deny-by-default in the licenses pipeline
  without the explicit key.

## 3. RUSTSEC-2024-0437 ignore (protobuf 2.28 stack overflow)

After the schema migration, `cargo deny check` surfaces a real
vulnerability: protobuf 2.28.0 is transitively pulled in by
`prometheus 0.13.4 → reach-supervisor`. RUSTSEC-2024-0437 is a stack
overflow caused by unbounded recursion when PARSING untrusted protobuf
input.

reach-supervisor uses prometheus to EXPORT metrics (serialise to the
Prometheus text format, served over an HTTP endpoint on port 8400). It
never parses protobuf input from the network or from untrusted sources,
so the vector does not apply. Added an explicit `[advisories] ignore`
with a detailed justification comment so the rationale is preserved
inline. Follow-up: upgrade to a prometheus version that uses protobuf
3.x, or drop the prometheus crate in favour of a pure-text-format
exporter.

## Verification

Local CI parity:

  `cargo fmt --check`                  clean
  `cargo deny check`                   advisories ok, bans ok, licenses ok, sources ok
  `cargo clippy --workspace -- -D warnings`  zero warnings
  `cargo build --workspace --release`   clean

After this lands on main, the Format + Deny jobs will start passing on
open PRs #1, #2, #3 without any per-branch rebasing (the fixes are on
main, not downstream). Worthwhile to cascade-rebase the three so they
pick up the fmt-normalised line-lengths in docker.rs (which PR #2
touches).
todie added a commit that referenced this pull request Apr 7, 2026
…4+ schema

Two pre-existing issues on main that were silently breaking CI on every
open PR (#1 release-please, #2 screenshot fix, #3 editorconfig) via the
Format and Deny jobs. Neither was introduced by the open PRs — they
landed in the CI/CD overhaul commit 'ea74fa4 feat: CI/CD overhaul +
process restart loop + zero clippy warnings' (the 'zero clippy warnings'
part was true; the 'cargo fmt --check passes' part apparently wasn't).

## 1. rustfmt violations across 17 files

`cargo fmt --check` on main produces 86 diff hunks across:

  crates/reach-cli/src/commands/{connect,create,destroy,exec,list,screenshot,serve,vnc}.rs
  crates/reach-cli/src/{config,docker,mcp}.rs
  crates/reach-cli/tests/{docker_types,e2e_container,mcp_types}.rs
  crates/reach-supervisor/src/{main,processes,signals}.rs

Applied `cargo fmt --all` with the project's rustfmt.toml (max_width=100).
No behavioral changes — pure whitespace/alignment/import-grouping edits.

## 2. deny.toml schema migration (cargo-deny 0.14+)

cargo-deny 0.14 removed or changed several schema keys:

- `[advisories] vulnerability = "deny"` → REMOVED (cargo-deny now
  always denies vulnerabilities in its pipeline).
- `[advisories] unmaintained = "warn"` → REJECTED. The field's
  semantics changed from severity ("deny"/"warn") to scope
  ("all"/"workspace"/"transitive"/"none"). Migrated to
  "workspace" — we only care about crates we directly depend on being
  marked unmaintained, not transitives we don't control.
- `[licenses] unlicensed = "deny"` → REMOVED (see
  EmbarkStudios/cargo-deny#611 for the migration note). cargo-deny now
  handles unlicensed crates as deny-by-default in the licenses pipeline
  without the explicit key.

## 3. RUSTSEC-2024-0437 ignore (protobuf 2.28 stack overflow)

After the schema migration, `cargo deny check` surfaces a real
vulnerability: protobuf 2.28.0 is transitively pulled in by
`prometheus 0.13.4 → reach-supervisor`. RUSTSEC-2024-0437 is a stack
overflow caused by unbounded recursion when PARSING untrusted protobuf
input.

reach-supervisor uses prometheus to EXPORT metrics (serialise to the
Prometheus text format, served over an HTTP endpoint on port 8400). It
never parses protobuf input from the network or from untrusted sources,
so the vector does not apply. Added an explicit `[advisories] ignore`
with a detailed justification comment so the rationale is preserved
inline. Follow-up: upgrade to a prometheus version that uses protobuf
3.x, or drop the prometheus crate in favour of a pure-text-format
exporter.

## Verification

Local CI parity:

  `cargo fmt --check`                  clean
  `cargo deny check`                   advisories ok, bans ok, licenses ok, sources ok
  `cargo clippy --workspace -- -D warnings`  zero warnings
  `cargo build --workspace --release`   clean

After this lands on main, the Format + Deny jobs will start passing on
open PRs #1, #2, #3 without any per-branch rebasing (the fixes are on
main, not downstream). Worthwhile to cascade-rebase the three so they
pick up the fmt-normalised line-lengths in docker.rs (which PR #2
touches).
…eject

coreutils base64(1) wraps at 76 chars by default, which the STANDARD
base64 decoder in the base64 crate rejects with "Invalid symbol 10,
offset 76" because it does not tolerate whitespace between symbols.

The one-line fix is passing -w 0 to the in-container base64 command.
Added a defensive whitespace strip on the decoder side too, in case
the CLI flag is ever unavailable on a non-coreutils runtime.

Fixes: TOD-302
Tested: `reach screenshot cf-setup -o /tmp/shot.png` now produces a
valid 1920x1080 PNG (verified with file(1) and pixel dimension check)
against a running Ubuntu 24.04 sandbox.
@todie
todie force-pushed the fix/screenshot-base64-wrap branch from b1e07ef to 15a9e51 Compare April 7, 2026 10:17
@todie
todie merged commit 92ea402 into main Apr 7, 2026
7 of 9 checks passed
@todie
todie deleted the fix/screenshot-base64-wrap branch April 7, 2026 10:20
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