Skip to content

fix(sandbox): detect restricted worker namespaces and fall back to one-shot sandboxes - #1954

Merged
l0gicgate merged 5 commits into
mainfrom
fix/1926-detect-worker-sandbox
Nov 1, 2026
Merged

l0gicgate merged 5 commits into
mainfrom
fix/1926-detect-worker-sandbox

Conversation

@JoeJoeflyn

@JoeJoeflyn JoeJoeflyn commented Oct 30, 2026 •

Copy link
Copy Markdown
Collaborator

Description

On Ubuntu 24.04 and elementary OS 8, AppArmor's unpriv_bwrap profile transitions unprivileged bwrap children into a profile that denies nested namespace creation (unshare(CLONE_NEWNS) returns EPERM). Because workers_supported() previously only verified Landlock ABI 3 availability, Strata assumed persistent workers were functional, causing persistent workers to fail on startup and leaving thumbnails and previews in an unhandled failure state.

This change:

  • Adds a startup capability probe (verify_namespaces()) in the browser worker supervisor that tests namespace unsharing before entering the control loop, cleanly failing early with exit code 1 if blocked.
  • Uses an immediate ready handshake byte in ProcessWorker::spawn() so worker capability failures are detected synchronously.
  • Drops the Command handle immediately upon child launch so socket EOF is delivered without waiting for the 12-second timeout.
  • Falls back to one-shot bubblewrap (preview-helper) and disables persistent workers via disable_workers() when persistent workers cannot be spawned.
  • Updates diagnostics to report LandlockUnavailable warning instead of problem/crash when workers are restricted.

Visual evidence

preview png_preview png_preview audio_preview

How to test

  1. Run Strata or diagnostics on an Ubuntu 24.04 / elementary OS 8 system where unprivileged user namespaces are restricted inside bwrap (unpriv_bwrap AppArmor profile active).
  2. Open a folder containing images, PDFs, audio files, or video files.
  3. Open the diagnostics view (F1 -> Diagnostics).

Expected result:
Thumbnails and previews load cleanly using one-shot sandboxes without decoder error messages or crashes. Diagnostics reports Sandboxed Workers with a warning rather than failing or reporting false success.

Related issue

Closes #1926

…e-shot sandboxes

Verify nested namespace capabilities during persistent worker initialization.
If restricted by environment security policies (such as AppArmor unpriv_bwrap
on Ubuntu 24.04 and elementary OS 8), disable persistent workers and gracefully
fall back to one-shot bubblewrap decoders.

Closes #1926
@github-actions github-actions Bot added the fix label Oct 30, 2026
@wmfeht

wmfeht commented Oct 30, 2026

Copy link
Copy Markdown
Collaborator

Needs changes. The fix targets the right cause, but there are three problems: any spawn error now turns off persistent workers for the rest of the session, the diagnostics finding blames the wrong thing, and the new tests race on a shared global flag.

The root cause is handled correctly. verify_namespaces() calls the same process::namespaces() that isolated_job() runs, from the same forked-supervisor context. So on a host where AppArmor's unpriv_bwrap profile restricts namespaces, the worker now exits before the handshake. Before this PR, every job failed inside isolated_job and the worker returned an empty Response::default(), which produced the "Thumbnail unavailable" reports. Dropping command after spawn_renderer is also needed: Command keeps child_socket open otherwise, so the parent would never see EOF. Isolation is not weakened. Worker::OneShot is the same read-only-bind bubblewrap path already used without Landlock ABI 3, and it keeps glycin's own sandbox.

Should fix

1. Any spawn failure turns off persistent workers for the whole process (src/sandbox/browser.rs:710-716)

Worker::spawn calls disable_workers() on every ProcessWorker::spawn error, not only the namespace probe failure. Errors that may be temporary now downgrade every thumbnail and preview to one-shot sandboxes until restart:

  • PrivateOutput::create or UnixStream::pair failing (EMFILE, a full /tmp)
  • fork/spawn returning EAGAIN, in spawn_renderer or in the worker's own probe fork
  • the 12 s WALL_TIME_LIMIT running out on the new ready read (browser.rs:866) on a loaded machine

Before this PR the error went back to the caller and the next lease retried.

The worker returns Err and exits, so the parent sees the same EOF whatever the cause. Suggested fix: have the worker report the namespace restriction explicitly, for example by writing 0 instead of 1 when verify_namespaces() fails. Call disable_workers() only on that byte, and keep propagating other errors as before. The current ready[0] != 1 branch (browser.rs:870) can't be reached today and would become that path.

2. Diagnostics blame Landlock on systems that support it (src/services/diagnostics.rs:482-484, src/ui/settings/diagnostics.rs:454-456)

When workers were disabled by the namespace probe, the check reports Finding::LandlockUnavailable. Its text reads: "The kernel doesn't support Landlock ABI 3, so each thumbnail starts its own sandbox…". That is false on the reporter's system (Linux 7.0) and points people to the wrong fix.

The same wrong message also appears without rendering whenever thumbnails tripped the fallback before Diagnostics was opened. In that case workers_supported() is already false when it's passed as supported (diagnostics.rs:367).

Suggested fix:

  • Track why workers are off (e.g. Landlock missing vs. namespaces restricted).
  • Add a dedicated finding, e.g. "Unprivileged namespaces are restricted inside the sandbox, for example by AppArmor, so each thumbnail starts its own sandbox and loads more slowly."
  • Add the string to all 13 catalogs, per AGENTS.md § Internationalization.

3. workers() now reads global state, and the new tests race on it (src/services/diagnostics.rs:482, src/services/diagnostics/tests.rs:102, src/sandbox/browser/tests.rs:750)

workers() takes supported as an injected value but now also reads sandbox::browser::workers_supported() itself. Its test therefore has to flip the process-wide WORKERS_AVAILABLE flag. cargo test runs tests on parallel threads in one process, so the two new tests interfere:

  • If disabling_workers_falls_back_to_oneshot_sandboxes calls enable_workers() between the diagnostics test's disable_workers() and its check, the diagnostics test gets Passed instead of Warning.
  • The reverse order breaks assert!(workers_supported()) at browser/tests.rs:759.

Neither test restores the flag if an assertion panics. Suggested fix: pass the post-render state into workers(), either as a second closure/argument or by having render return it, and drop the global from the test.

4. The browser test only exercises the flag, not the new behaviour (src/sandbox/browser/tests.rs:749-761)

The test checks the setter/getter and the existing !workers_supported() → OneShot branch, which decoder_cannot_reopen_or_mutate_a_read_only_source_descriptor already covers at tests.rs:649-653. Nothing exercises the new parts: falling back after a failed ProcessWorker::spawn, or the disable decision. Suggested fix: inject the spawner (e.g. Worker::spawn_with(|| Err(..))) so the fallback and the "only disable on restriction" rule from item 1 can be tested without global toggles. Otherwise remove the test.

5. The sandbox docs still describe Landlock as the only trigger (docs/preview-sandbox.md:372-374)

The docs say the pool keeps one-shot sandboxes "On systems without Landlock ABI 3". They should also cover the startup namespace probe, the ready handshake, and the fact that a restricted environment falls back for the rest of the session.

Nits

  • src/sandbox/browser.rs:862-873: worker startup now blocks the single thumbnail-launcher thread for the full startup. That covers bwrap, exec of /app/strata, seccomp setup, the probe fork plus unshare, and the handshake byte (up to 12 s). Before, it ended once bwrap reported its namespace init. Spawns for both pools, and retire_idle, are serialized behind it. That is probably acceptable, but it's worth checking cold-start thumbnail latency with 4+ workers.
  • src/sandbox/browser/tests.rs:751-752: initial is read right after enable_workers(), so it only reflects Landlock support. Naming it accordingly, or removing it along with item 4, would make the intent clearer.

@JoeJoeflyn

Copy link
Copy Markdown
Collaborator Author

Review and cleanup

Prepared locally, not pushed: d172ab82 (fix(sandbox): keep transient worker startup failures retryable). The pre-push full Rust run is not green. One failure reproduces on unmodified main (e7f3d068); I have not widened this PR to change unrelated notification/window tests or bypassed the pre-push rule. CI still covers the existing remote head, not these local fixes.

Merged main at e7f3d068, without conflicts.

Feedback addressed

  1. src/sandbox/browser/worker.rs explicitly reports namespace permission denial. Probe fork/wait failures, resource exhaustion, EOF, malformed handshakes and startup timeouts remain errors. WorkerAvailability::spawn disables persistent workers only for the explicit denial and reuses the existing Worker::OneShot implementation. The shared process::wait now retains exit status, so an unsuccessful/killed probe is not mistaken for a restriction.
  2. Diagnostics distinguishes missing Landlock from restricted namespaces, including restrictions discovered before Diagnostics opens and during its sample render. The new finding is translated in all 13 catalogs.
  3. Diagnostics receives its before/after availability probe as a closure. Tests use local state; the global enable/disable test hooks are gone.
  4. Replaced disabling_workers_falls_back_to_oneshot_sandboxes with injected-spawner coverage for sticky restriction fallback and retryable startup failures. The existing decoder_cannot_reopen_or_mutate_a_read_only_source_descriptor still owns legacy fallback/source protection. Folded workers_warn_when_workers_disabled_during_render into the existing diagnostics coverage owner, now workers_report_capability_reasons_before_and_after_rendering. Extended the existing diagnostics page/report test instead of adding another GUI launch.
  5. Updated docs/preview-sandbox.md and docs/diagnostics.md. Existing worker QA scenarios now recognize either one-shot reason through their shared fallback log phrase; no duplicate scenarios or decoder paths were added.

Validation

Coverage includes startup/protocol policy, both pool callers, source protection, diagnostic classification/presentation/report copying, catalogs, real worker reuse/replacement/retirement, and preview rendering. The merge brought dependency/QA-harness changes, so full pinned suites were also run. Existing pinned image/storage and build caches were reused; GUI checks used private displays/buses.

  • ./scripts/test-headless.py sandbox::browser:: — 25 passed.
  • ./scripts/test-headless.py diagnostics:: — 12 passed.
  • ./scripts/test-headless.py i18n:: — 12 passed.
  • Final code, inside the verified pinned container: ./scripts/test-headless.py -- sandbox::browser:: diagnostics:: i18n:: — 49 passed.
  • ./scripts/quality.sh fmt, ./scripts/quality.sh clippy, git diff --check — passed.
  • ./scripts/quality.sh test — failed: 3,685 passed, 3 failed, 11 ignored. Failures were ui::progress_dock::tests::already_hovered_notifications_start_paused_and_pinning_is_independent, ui::window::composition::tabs::tests::restored_tabs_open_once_a_worker_checks_their_folders, and ui::window::tests::keyboard_dispatch::layout_fallback_editing_keys_run_the_focused_text_fields_own_actions.
  • Scoped default-renderer reproduction of those three tests in the pinned container — keyboard test passed; notification and tab tests failed. Scoped reproduction on unmodified main — keyboard and tab tests passed; the same notification test failed. The notification/tab pair also passed under the seeded test-headless.py environment. These results are retained, not reported as a green full suite.
  • ./scripts/qa.sh — failed: 637 passed, 1 failed, 2 ignored. All four worker reuse/retirement runs passed. markdown-equations-keep-prose/columns timed out under full-run load; ./scripts/qa.sh markdown-equations-keep-prose/columns then passed (1 selected run). No full-suite rerun was used to mask the failure.

Manual inspection through qa-drive.sh: Diagnostics reported working pooled workers. With four workers and 16 uncached SVGs, all thumbnails completed about 0.91 s after window presentation; the four ready handshakes spanned about 0.32 s. A scratch real-worker protocol probe injected unshare errors: EPERM/EACCES produced the restriction byte; EAGAIN/ENOMEM/ENOSPC produced EOF/failure without that byte; unrestricted startup produced ready. An actual Ubuntu/elementary AppArmor host was not available. The private drive session and temporary baseline worktree were removed.

Pending owner decision: authorize pushing with the documented unrelated baseline failure, or handle the notification/window test failures separately first. No validation override has been assumed.

@JoeJoeflyn

Copy link
Copy Markdown
Collaborator Author

LGTM @wmfeht

Persistent workers were disabled for the rest of the process after any
startup error, including temporary ones such as EMFILE, EAGAIN or a slow
start, and Diagnostics blamed a missing Landlock ABI 3 even on kernels
that support it.

The supervisor now reports a namespace permission denial explicitly in
its startup handshake. Only that answer switches the pool to one-shot
sandboxes; other startup failures fail the request and the next one
retries. Diagnostics distinguishes restricted namespaces from missing
Landlock (translated in all catalogs) and receives the support state
as an argument instead of tests toggling a global flag. The browser
worker QA scenarios detect either fallback by its shared log phrase.

Refs #1926
@wmfeht

wmfeht commented Oct 31, 2026

Copy link
Copy Markdown
Collaborator

Review fixes pushed

Merged main at 0c6528e7, no conflicts. All changes from the review are in e6b9715f (fix(sandbox): fall back only when worker namespaces are denied).

Review findings and how they were addressed

  1. Any spawn error turned off persistent workers for the rest of the session. The supervisor now reports the restriction explicitly in its startup handshake (wire::READY / wire::NAMESPACES_RESTRICTED). In worker.rs, namespaces_restricted() forks a probe that runs the same process::namespaces() as every job. Only a PermissionDenied error (EPERM/EACCES) counts as restricted. A failed fork or probe, a killed probe, EAGAIN/ENOMEM/ENOSPC, EOF before the handshake, an unknown byte or the startup timeout all stay Err: the request fails and the next lease starts a supervisor again, as before this PR. process::wait now keeps the exit code (process::exit_code), so a failed probe isn't mistaken for a restriction. The fallback warning is logged once.

  2. Diagnostics blamed Landlock. sandbox::browser::worker_support() now returns Available, LandlockUnavailable or NamespacesRestricted. The new Finding::NamespacesRestricted reads: "The system blocks nested user namespaces, for example through AppArmor, so each thumbnail starts its own sandbox and loads more slowly." It's in all 13 catalogs. The check reports it both when the fallback happened before Diagnostics opened and when the sample render is what discovers it.

  3. workers() read global state and the tests raced on it. workers() now receives the support state as a closure, so its test uses local state. The enable_workers/disable_workers hooks are gone.

  4. The browser test only exercised the flag. It's replaced by two table tests of the decisions this PR adds:

    • only_denied_namespaces_make_supervisors_report_a_restriction: probe error → restricted or not.
    • only_an_explicit_restriction_byte_disables_persistent_workers: handshake byte, EOF or unknown byte → result.

    workers_warn_when_workers_disabled_during_render is folded into workers_report_why_thumbnails_use_one_shot_sandboxes, which also replaces workers_without_landlock_warn_without_rendering.

  5. Docs. docs/preview-sandbox.md describes the startup probe, the handshake and which failures are retried. docs/diagnostics.md covers the new warning. docs/unsafe-code.md notes the probe child, which keeps the control socket but only runs namespace setup and exits.

browser-workers-reuse-processes and idle-workers-exit-and-restart previously matched Landlock ABI 3 unavailable to detect the fallback. They now match the phrase both fallbacks share, retaining one-shot sandboxes.

Validation

  • ./scripts/test-headless.py -- sandbox::browser:: diagnostics:: i18n::: 48 passed.
  • ./scripts/qa.sh browser-workers-reuse-processes idle-workers-exit-and-restart icons-rename-reuses-thumbnail-details list-icons-fill-duration-cached stl-thumbnails-render-only-valid-models: 7 runs passed. I used the Docker engine because Podman isn't installed on this host.
  • A temporary assertion in browser-workers-reuse-processes/columns confirmed that supervisors in the container start through the new handshake (no fallback, at least one browser sandbox started). It was not committed.
  • ./scripts/quality.sh fmt and ./scripts/quality.sh clippy are clean.
  • CI is green. The QA scenarios job was skipped by the workflow.

Reproduced and verified on Ubuntu 24.04 with AppArmor bwrap-userns-restrict

Stock Ubuntu 24.04 sets kernel.apparmor_restrict_unprivileged_userns=1, but ships bwrap-userns-restrict only under apparmor-profiles extras. elementary OS 8 enables it. With the profile loaded in an Ubuntu 24.04.5 QEMU guest (kernel 6.8.0-139, bubblewrap 0.9.0):

  • Plain bwrap --unshare-all … true succeeds, and the bwrap child runs as bwrap//&unpriv_bwrap (enforce). A nested unshare -Urm inside bwrap fails with EPERM. The kernel logs apparmor="DENIED" operation="capable" profile="unpriv_bwrap" capname="sys_admin".
  • Release 1.1.1: ThumbnailCounts { eligible: 5, requested: 5, started: 5, completed: 0, … }, and no preview appears. This reproduces No thumbnails #1926.
  • This branch, built against the guest's glibc:
    • WARN strata::sandbox::browser: Job namespaces restricted; retaining one-shot sandboxes is logged once, and no persistent supervisor starts.
    • ThumbnailCounts { eligible: 5, requested: 5, started: 5, completed: 5, applied: 5, … }.
    • Quick Preview of a PNG renders.
    • Settings → Diagnostics shows Passed 24, Warnings 2, Problems 0. Sandboxed workers shows the new namespaces warning. Preview sandbox, PDF, video and media sample checks pass.

Follow-ups

  • The PR description still says diagnostics report LandlockUnavailable when workers are restricted, and that a failed spawn always falls back. Both changed in e6b9715f.

@l0gicgate

Copy link
Copy Markdown
Contributor

Review

Merged main at 8760486a (merge commit 7ec730c5), no conflicts. One cleanup commit, ab38674d, rewraps the new startup-probe paragraph in docs/unsafe-code.md to the file's 80-column wrap; no code changes.

Findings

I re-read the whole diff against the updated main, including the earlier review items. All five are addressed in e6b9715f:

  • Only the explicit NAMESPACES_RESTRICTED handshake byte flips the pool to one-shot sandboxes; EOF, an unknown byte, a failed probe fork and the startup deadline still fail only that request and the next lease spawns again. probe_status maps only PermissionDenied (EPERM/EACCES) to the restricted exit code.
  • Dropping command before the handshake read is correct: it held the worker's socket end, which would otherwise hide an early exit until the 12 s timeout. It only moves a drop that already happened at function return.
  • Diagnostics now distinguishes LandlockUnavailable from NamespacesRestricted, re-checks support after the sample render, and the new string is translated in all 13 catalogs.
  • The diagnostics workers() check takes support as a closure; the global enable/disable test hooks are gone, so no cross-test races remain.
  • The two new browser tests and the folded diagnostics table test exercise the new decisions (probe classification, handshake parsing, before/after support states). Every new comment carries a non-obvious invariant or rationale, so none were removed.

Nothing merge-blocking found. The PR description still describes the first revision (always falling back on spawn failure, reporting LandlockUnavailable); worth updating to match e6b9715f.

Validation (review worktree, Podman)

  • ./scripts/test-headless.py -- sandbox::browser:: diagnostics:: i18n:: sandbox_helper::tests:: — 80 passed.
  • Full ./scripts/test-headless.py — 3732 passed, 0 failed, 11 ignored.
  • cargo fmt --all --check, cargo clippy --all-targets --all-features -- -D warnings, git diff --check — clean.
  • ./scripts/qa.sh --test-threads 6 browser-workers-reuse-processes idle-workers-exit-and-restart — 4 runs passed.
  • CI on ab38674d is green (the QA scenarios job is dispatch-only on PRs).

I did not have an AppArmor-restricted host; the restricted path relies on the unit tests plus the Ubuntu 24.04 reproduction already recorded above.

@l0gicgate
l0gicgate merged commit 8e00c8b into main Nov 1, 2026
11 checks passed
@l0gicgate
l0gicgate deleted the fix/1926-detect-worker-sandbox branch November 1, 2026 06:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

No thumbnails

3 participants