The AppImage artifact test only validated package structure (extraction,
AppDir layout, asar contents) — runtime regressions like frame-fix-wrapper
syntax errors, bad asar patches, or Electron startup crashes silently
passed CI. The .deb path already ran `--doctor` as a smoke check; the
AppImage path now has parity plus a 10s headless launch under Xvfb.
`setsid` + `kill -- -PGID` is load-bearing: xvfb-run's EXIT trap leaks
Xvfb on signal kill, so running the whole stack in its own process group
lets the teardown reap xvfb-run, Xvfb, dbus, AppRun, electron, and zygote
children together. `procps` (for pkill), `dbus-x11`, and `xvfb` added to
the CI apt line.
The headless probe catches main-process startup failures only — GPU /
renderer-process crashes like #583 leave the main process alive and pass
this check; that scope disclaimer is inlined at test-artifact-appimage.sh
lines 114-117 so future contributors don't try to claim #583 coverage by
switching Xvfb off.
Co-authored-by: Sum Abiut <sabiut@users.noreply.github.com>
Reframe the assert_setuid comment from "guards against the old %post
chmod pattern" to "guards against any regression that strips the suid
bit" — including but not limited to a %post chmod revert.
The assertion itself catches any loss of the setuid bit on
chrome-sandbox, not just the specific %post chmod regression path.
Per review feedback on #595.
Move the suid bit on chrome-sandbox into the rpm spec's %files section
via %attr(4755, root, root). The previous %post chmod 4755 only ran on
fresh installs and silently regressed when the scriptlet was skipped
(e.g., --noscripts), leaving a non-suid chrome-sandbox that breaks
sandboxing on every launch.
Also add an assert_setuid helper to tests/test-artifact-common.sh and
wire it up in test-artifact-rpm.sh so a future spec regression to the
old %post pattern fails CI rather than shipping silently.
Verified: built rpm in fedora:42 container, installed via dnf,
ls confirms -rwsr-xr-x on chrome-sandbox, %post no longer chmods.
CI fail on PR #585: the existing log_session_env block test did
exact-line matching on the env block contents, so adding
CLAUDE_DISABLE_GPU to log_session_env's key list (88676f4) shifted
the closing '}' index and broke both block tests.
Updates both tests in launcher-common.bats:
- "all required keys" — sets CLAUDE_DISABLE_GPU=1, asserts new line
at index 11, '}' moves to index 12
- "unset/empty values render as KEY=" — asserts the new key emits
empty form at index 11
70/70 launcher-common.bats pass locally.
Co-Authored-By: Claude <claude@anthropic.com>
Adds _doctor_check_recent_crashes, called from run_doctor before the
log-file section. When systemd-coredump shows ≥3 Electron crashes in
the last 7 days, surfaces a [WARN] with two workarounds (Settings
toggle, CLAUDE_DISABLE_GPU=1) and a link to the tracking issue.
Filters by the caller-supplied electron_path when entries match, falls
back to all-electron entries with a footnote when they don't (covers
AppImage's transient mount paths and other Electron apps installed
side-by-side).
Silent when coredumpctl isn't on PATH (non-systemd hosts), when there
are zero matches, or when the count is below threshold.
Co-Authored-By: Claude <claude@anthropic.com>
Mitigation for the Chromium GPU process FATAL exhaustion tracked in
#583. CLAUDE_DISABLE_GPU=1 adds --disable-gpu and
--disable-software-rasterizer to Electron's argv, providing the same
effect as the upstream Settings → disable hardware acceleration toggle
but persistable via the environment.
Co-occurrence with the existing XRDP block does not stack duplicate
flags: a single _disable_gpu sentinel gates the args+= push.
CLAUDE_DISABLE_GPU joins the other CLAUDE_* keys logged by
log_session_env so bug reports include the value.
This is a workaround, not a fix. The underlying Electron/Chromium GPU
process lifecycle issue remains tracked at #583.
Co-Authored-By: Claude <claude@anthropic.com>
* verify(cowork): static-grep shipped asar for PR #555 markers
D6 of #559's followup plan: post-build check that greps the shipped
app.asar for 9 known cowork patch markers and exits non-zero if any
are missing. Catches the half-patched-asar failure mode from PR #555,
where two of three failed gates had no else branch and the build log
showed "Applied 10 cowork patches" instead of warning.
- scripts/cowork-patch-markers.tsv: single source of truth.
Tab-separated name<TAB>pcre<TAB>sample. Both verify and BATS read it.
- scripts/verify-cowork-patches.sh: accepts a .js, an .asar (npx
@electron/asar extract), or a directory containing
app.asar.contents/.vite/build/index.js. Exits 0/1/2.
- tests/verify-cowork-patches.bats: regex-matches-sample integrity,
positive full fixture, per-marker negative fixtures, input-shape
coverage. 9 new BATS cases.
- .github/workflows/build-amd64.yml: runs verify against the deb
build's asar. Pinned to deb because the patched JS is identical
across formats.
Validated end-to-end against the pinned 1.5354.0 installer:
unpatched -> 9/9 miss; cowork.sh patched -> all 9 present.
Refs #559.
Co-Authored-By: Claude <claude@anthropic.com>
* verify(cowork): share TSV parser between verify.sh and BATS
Realises the library-mode plumbing the previous commit added but
didn't use: BATS now sources verify-cowork-patches.sh and calls
load_markers, so a TSV format change cannot desync the two consumers.
Drops the duplicate parser in tests/verify-cowork-patches.bats.
Also tightens main()'s loop (for over indexed while, drop redundant
missing counter) and the BATS index loops.
Behaviour-preserving; bats tests/verify-cowork-patches.bats still 9/9.
Co-Authored-By: Claude <claude@anthropic.com>
* rename: verify-cowork-patches → verify-patches (generic)
Rename the verify infra to make its generic intent explicit. Per
sabiut's review note on #575, the script + TSV are reusable for
non-cowork patch sets in principle — drop "cowork" from the script
and BATS filenames to reflect that, and accept an optional second
arg for the marker TSV path so other patch sets can plug their own
TSV in without forking the script.
The TSV itself stays cowork-specific (`cowork-patch-markers.tsv`)
because its contents are cowork markers; the script defaults to it
so existing CI keeps working without changes beyond the rename.
Routing implication noted by sabiut: filename now lives under
`/tests/` → @sabiut codeowner mapping (intentionally; the verify
infra is generic). Cowork-specific marker changes still touch the
TSV under `/scripts/`, which routes to @aaddrick/@RayCharlizard via
the cowork-* CODEOWNERS rule.
Co-Authored-By: Claude <claude@anthropic.com>
---------
Co-authored-by: Claude <claude@anthropic.com>
* doctor: detect IBus/GTK misconfigurations that break input (#550)
Adds _doctor_check_im_modules helper covering the four input-method
failure modes from #545:
- ibus-gtk3 package missing while GTK_IM_MODULE=ibus
- GTK immodules cache stale (active module not listed by
gtk-query-immodules-3.0 --update-cache fixes it)
- XWayland session routing IBus through XIM (lossy for some IMEs;
informational note pointing at CLAUDE_USE_WAYLAND=1 for native
Wayland IME)
- CLAUDE_GTK_IM_MODULE override visibility (informational, so
users can verify the resolved value)
Each check is gated so it only fires when relevant — e.g. the
package check is skipped when GTK_IM_MODULE isn't ibus, the cache
check is skipped when gtk-query-immodules-3.0 isn't installed, and
the package check returns silently on distros without dpkg/rpm/pacman
to avoid false negatives.
Adds tests/doctor.bats with 17 cases covering each gating branch and
the _cowork_pkg_hint mapping for ibus-gtk3 (Arch maps to plain ibus
since it bundles the GTK3 immodule).
Hoists _distro_id resolution to the top of run_doctor so the IM
check and the existing Cowork section share one /etc/os-release
read.
Closes#550. Refs #545, #549.
Co-Authored-By: Claude <claude@anthropic.com>
* doctor: simplify IM-check helper and DRY out doctor.bats setup
Mechanical clean-up of the #550 diff after self-review:
scripts/doctor.sh
- tighten the _doctor_check_im_modules docblock: drop the "each
check is gated" paragraph (self-evident in the code) and inline
the XWayland/XIM rationale into the failure-mode bullet
- drop the inline section comments that just restated the next
block's purpose; keep the rc=1/rc=2 comment because the value
distinction is the load-bearing detail
- replace the `local _pkg_rc=0; ... || _pkg_rc=$?; if ((_pkg_rc == 1))`
dance with a `case $?` on the direct call
tests/doctor.bats
- hoist the `command -v gtk-query-immodules-3.0 → not-found` shim
into a `_skip_gtk_query` helper (it was duplicated across 11 of
the 17 cases)
- default `_pkg_installed() { return 2; }` in setup so per-test
stubs only appear when the test cares about rc=0 or rc=1
- drop dead `_skip_gtk_query` calls from cases where the function
returns earlier (no IM selected, package warn fires) so the
shim is only present where it actually changes behaviour
No behaviour change — all 17 doctor.bats cases still pass, plus the
68 launcher-common.bats cases. Shellcheck is unchanged from baseline.
Co-Authored-By: Claude <claude@anthropic.com>
---------
Co-authored-by: Claude <claude@anthropic.com>
* launcher: add CLAUDE_GTK_IM_MODULE opt-in override (#549)
Some users hit broken IBus integration on Linux and have to wrap
every launch with `GTK_IM_MODULE=xim claude-desktop`. Forcing this
for everyone would break CJK input methods, compose keys, and
dead-key sequences (rationale in #545), so this lands as opt-in.
When `CLAUDE_GTK_IM_MODULE` is set non-empty, `setup_electron_env`
exports `GTK_IM_MODULE=$CLAUDE_GTK_IM_MODULE` before the Electron
exec and logs the override (with the prior value) to launcher.log.
Unset/empty leaves `GTK_IM_MODULE` alone — no behavior change for
users not affected by the IBus issue.
Adds a TROUBLESHOOTING.md section documenting symptoms, valid
values, the trade-off note for `xim`, and BATS coverage for the
set / unset / empty / unset-prior cases.
Closes#549. Refs #545.
Co-Authored-By: Claude <claude@anthropic.com>
* launcher: tighten CLAUDE_GTK_IM_MODULE comment and docs (#549)
Trim the in-source comment to what's not implied by the guard, drop
the underscore prefix on the local, and remove a redundant trailing
sentence + duplicated trade-off line from TROUBLESHOOTING.md. No
behavior change; all 68 BATS tests still pass.
Co-Authored-By: Claude <claude@anthropic.com>
---------
Co-authored-by: Claude <claude@anthropic.com>
* launcher: log session/IME env block at startup (#548)
Adds log_session_env, called once per launch from each packaging
target (deb, rpm, AppImage, Nix). Emits a single env={ ... } block
covering display (XDG_SESSION_TYPE, WAYLAND_DISPLAY, DISPLAY,
XDG_CURRENT_DESKTOP), IME (GTK_IM_MODULE, XMODIFIERS, QT_IM_MODULE),
and Claude-specific overrides (CLAUDE_USE_WAYLAND,
CLAUDE_TITLEBAR_STYLE, CLAUDE_GTK_IM_MODULE).
Empty/unset values are emitted as `KEY=` (rather than omitted) so
absence is unambiguous in bug reports. Pure observability — no
behavior change.
Closes#548
Co-Authored-By: Claude <claude@anthropic.com>
* test: consolidate log_session_env BATS coverage (#548)
Collapse the four log_session_env cases into two, and tighten the
assertions in both:
- Old test 1 (substring match per key) + old test 4 (block braces
on their own lines) → one test using exact-line equality on the
`lines` array. Locks block structure and per-key formatting in
a single pass; substring matching could not catch a regression
that re-ordered keys, dropped indentation, or merged lines.
- Old test 2 (unset values are KEY=) + old test 3 (empty-string
is KEY=) → one test covering both code paths. Exact-line match
proves the value after `=` is truly empty; the previous
`*'KEY='*` substring would have matched `KEY=value` and the
old test-3 regex was fragile (depended on trailing newline
being literal `$'\n'` vs end-of-string `$`).
Net: 77 → 42 lines, 4 → 2 cases, stronger guarantees. No change to
the helper itself or the call sites — issue #548 acceptance criteria
still hold.
---------
Co-authored-by: Claude <claude@anthropic.com>
* test: isolate cleanup_stale_cowork_socket BATS from host pgrep state
Stub `pgrep` inside the `cleanup_stale_cowork_socket: removes stale
socket file` test so it returns nonzero. Without this, the test fails
on any developer machine running Claude Desktop because the real
`pgrep -f cowork-vm-service\.js` finds the live daemon and the
function correctly bails out before removing the socket — the
function's "daemon alive, leave socket alone" branch was leaking into
a test that was supposed to exercise the "no daemon, remove stale
socket" branch.
Fixes#533
* test: address PR #534 review — drop no-op export and stale comment
Per aaddrick's review:
- export -f pgrep is a no-op since cleanup_stale_cowork_socket runs
in the same shell and bash function lookup beats PATH
- the "socat connection should fail" comment predates the move to
pgrep checks and is now misleading
* feat(linux): hybrid titlebar mode for clickable in-app topbar
Default `CLAUDE_TITLEBAR_STYLE` is now `hybrid`: native OS frame
plus a BrowserView preload shim that convinces claude.ai's bundle
to render its in-app topbar (hamburger / sidebar / search / nav /
Cowork ghost). Stacked layout instead of Windows's combined bar,
but every button is clickable.
Why not the upstream `frame:false` + WCO config: investigation
(see docs/learnings/linux-topbar-shim.md) ruled out
`titleBarOverlay`, `titleBarStyle:'hidden'`, and the `.draggable`
CSS class as the source of the topbar click-eating drag region.
The remaining cause is a Chromium-level implicit drag region for
`frame:false` windows that exists on both X11 and Wayland and has
no Electron-API knob. With `frame:true` the OS handles dragging
and Chromium pushes no drag-region map, so the buttons receive
mouse events normally.
Modes:
- `hybrid` (default) — system frame + shim, topbar visible and
clickable
- `native` — system frame, no shim, no in-app topbar
- `hidden` — frameless + WCO config, matches Windows/macOS
upstream; topbar visible but not clickable on Linux. Kept for
Wayland comparison and future investigation
Tests: tests/launcher-common.bats grew 16 cases covering
`_resolve_titlebar_style`, `build_electron_args` flag selection
per mode, and `setup_electron_env` env-var wiring per mode.
`claude-desktop --doctor` now reports the resolved mode and
warns when `hidden` is set.
Co-Authored-By: Claude <claude@anthropic.com>
* docs(learnings): add hybrid-mode screenshot
Visual reference of the stacked layout: DE-drawn titlebar on top
with native window controls, claude.ai's in-app topbar
(hamburger / search / back-forward) immediately below it.
Co-Authored-By: Claude <claude@anthropic.com>
* docs(learnings): fix codespell hit (Pre-emptive → Preemptive)
Codespell flags hyphenated "Pre-emptive" as a misspelling of
"Preemptive". Drops the hyphen to clear the spellcheck CI gate
on PR #538.
Co-Authored-By: Claude <claude@anthropic.com>
---------
Co-authored-by: Claude <claude@anthropic.com>
* feat(bwrap): support {src, dst} mount form for distinct host/sandbox paths
Extends coworkBwrapMounts (#339) so additionalROBinds and additionalBinds
accept entries of the form { src, dst } in addition to the existing string
form. This unlocks the persistent /tmp use case: the default --tmpfs /tmp
gets wiped between Bash tool calls because of --die-with-parent, and the
old string-only API (--bind p p) had no way to map a host directory under
$HOME onto /tmp inside the sandbox without exposing the host /tmp itself.
Validation:
- src: same checks as the string form (absolute, not in
FORBIDDEN_MOUNT_PATHS, $HOME constraint when RW)
- dst: absolute and non-forbidden only — the $HOME constraint is
intentionally skipped since the whole point of the form is to map
outside $HOME (e.g. /tmp)
- malformed objects are filtered out with a warning, matching the
existing string-validation behavior
Doctor (--doctor) renders the object form as "src -> dst" in both the
Python and Node parser branches.
100% backwards compatible: the string form is preserved unchanged. The 36
existing tests pass; 13 new tests cover accept/reject paths, mixed
string+object configs, the persistent-/tmp recipe end-to-end, and the
doctor rendering (58/58 total).
Closes#530
---
Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <claude@anthropic.com>
* docs(configuration): document {src, dst} mount form
Refs #530
---
Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <claude@anthropic.com>
* chore(bwrap): address PR #531 review feedback
- doctor: warn when an additional mount's dst lands on a default RO
mount (/usr, /etc, /bin, /sbin, /lib, /lib64, or subpaths). bwrap
honors the later mount, so the user's bind silently replaces the
default — a config footgun, not an escape, but worth surfacing
(RayCharlizard issue 1)
- docs(configuration): note the shadowing implication under
"Distinct host/sandbox paths" (RayCharlizard issue 2)
- test(bwrap-config): pin the reject contract for dst under a
forbidden path (e.g. /proc/self), beyond the existing exact-match
case (RayCharlizard issue 3)
- bwrap-config: harmonize the rejected-mount warning text — the
string-form path now reads "rejected mount" like the object-form
variants (RayCharlizard issue 4)
Tests: 61/61 passing (3 new: 1 reject-subpath + 2 doctor shadow
positive/negative).
Refs #530
---
Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <claude@anthropic.com>
---------
Co-authored-by: Claude <claude@anthropic.com>
* ci: run BATS test suite on push and PR
The /tests/ directory has 186 BATS tests
(launcher-common, launcher-xrdp-detection, and four
cowork-*.bats files) but no workflow ever invoked `bats`
— the entire suite was effectively inert.
A regression in launcher-common.sh or
cowork-vm-service.js would not fail any check,
including the BATS suite added by PR #395.
Add a standalone tests.yml workflow that:
- installs bats + nodejs
- runs `bats tests/*.bats`
- executes on every PR
- executes on pushes to main
Push triggers are path-filtered to:
- tests/
- scripts/
- .github/workflows/tests.yml
PR triggers remain unfiltered so required-check
behaviour stays predictable.
Kept this standalone rather than extending
test-artifacts.yml so unit tests run in seconds
instead of waiting for full artifact builds.
This can be promoted to a build gate later once
it proves stable in CI.
CODEOWNERS
- adds /.github/workflows/tests.yml under @sabiut
- keeps /tests/cowork-*.bats ownership with @RayCharlizard
This PR only enables CI coverage for existing tests
and does not modify cowork test logic.
* fix(tests): unset XDG_CONFIG_HOME in cowork-bwrap-config setup
The "doctor: reports custom bwrap mounts" and "doctor: warns
about disabled critical mount /usr" tests failed in CI but
passed locally.
Root cause:
- _doctor_check_bwrap_mounts in scripts/doctor.sh resolves
the config dir via ${XDG_CONFIG_HOME:-$HOME/.config}/Claude
- The test setup() only sandboxes HOME via TEST_TMP
- GitHub Actions runners export XDG_CONFIG_HOME ambient
- Function reads the runner's real config dir, not the test
fixture, and silently emits no output
- Assertions on /opt/tools, WARN, etc. fail
Surfaced by PR #520 wiring BATS into CI for the first time;
the bug existed before but was hidden by the suite never
running.
Fix: unset XDG_CONFIG_HOME in setup() so the function falls
back to \$HOME/.config (which is sandboxed). Comment in the
file documents why HOME alone is insufficient.
Verified: 186/186 pass with XDG_CONFIG_HOME set ambient
(reproduces CI env).
* fix: strip CRLF from cowork-plugin-shim.sh during staging
The shim originates from the upstream Windows .exe extract and ships
with CRLF line endings. Bash fails to exec a script with CRLF
shebangs/commands ("$'\r': command not found", syntax errors at
function braces), so on Nix where the installed file is read-only the
Claude Code subprocess crashes immediately and every cowork session
reports `process_crashed`. Debian/AppImage installs inherit the same
CRLF file but bite less often because the shim is only invoked once
cowork is actively used.
Normalise at the single staging point both the deb and Nix paths
read from. The conversion is a no-op on LF-only input, so if upstream
ever switches to LF this patch remains safe.
Fixes#499
Reported-by: @olafkfreund
Co-Authored-By: Claude <claude@anthropic.com>
* test: use read builtin instead of sha256sum | awk
Style guide prefers parameter expansion / bash builtins over forking awk.
Same ground covered: the first whitespace-separated field of sha256sum
output is captured.
Co-Authored-By: Claude <claude@anthropic.com>
---------
Co-authored-by: Claude <claude@anthropic.com>
* feat: add BATS unit tests for launcher-common.sh
scripts/launcher-common.sh is 798 lines handling critical startup logic —
display detection, Electron arg building, stale lock cleanup, cowork daemon
cleanup, and the full --doctor diagnostic system — but had zero test coverage.
A regression in any of these functions could silently break app launches across
display servers and package formats.
Add 48 BATS tests covering:
- setup_logging / log_message (XDG_CACHE_HOME fallback)
- detect_display_backend (X11, Wayland, XWayland, Niri auto-detect)
- build_electron_args (all display × package-type combinations)
- setup_electron_env (ELECTRON_FORCE_IS_PACKAGED, title bar)
- cleanup_stale_lock (dead PID removal, live PID preservation)
- cleanup_stale_cowork_socket (stale unix socket removal)
- Doctor helpers:
- _pass / _fail / _warn / _info output
- _cowork_distro_id
- _cowork_pkg_hint (distro-specific package mapping)
- _electron_version
Tests run fully sandboxed:
- HOME, XDG_CACHE_HOME, XDG_CONFIG_HOME, and XDG_RUNTIME_DIR redirected to a temp directory
- Host display variables cleared in setup() to prevent state leakage
* refactor: extract has_electron_arg helper to reduce test boilerplate
Replace repeated loop-and-flag patterns across 7 build_electron_args
tests with a shared has_electron_arg helper that supports glob matching.
Removes ~40 lines of duplicated code with no change in test coverage.
* fix(cowork): forward CLAUDE_CODE_OAUTH_TOKEN to VM spawn env (#482)
buildSpawnEnv and BwrapBackend.spawn both stripped every CLAUDE_CODE_*
env var from the daemon's process.env via filterEnv(process.env,
['CLAUDE_CODE_']) -- including CLAUDE_CODE_OAUTH_TOKEN, the standard
auth channel for the in-VM claude binary. The bwrap sandbox mounts
home as an empty --dir, so ~/.claude/.credentials.json is inaccessible
inside; env is the only viable auth path. Result: every shell tool
call returned "Not logged in. Please run /login".
Upstream's seA() does include the token in the spawn env it assembles,
but on Linux that payload isn't reaching the daemon's params.env, so
the daemon's inherited process.env is the only surviving source.
Stripping it severed auth.
Introduce FORWARDED_ENV_KEYS = ['CLAUDE_CODE_OAUTH_TOKEN'] plus a
forwardAuthEnv helper that re-adds the token from process.env when
appEnv doesn't provide one. Extract buildBaseSpawnEnv as the shared
env-construction path for both spawn sites so the filter/forward logic
can't drift.
Diagnosis and reference diff by @pb3ck in #482. This PR extends the
fix to BwrapBackend.spawn (the second call site), factors the shared
helper, and adds regression coverage.
Co-Authored-By: Claude <claude@anthropic.com>
* refactor(cowork): fold forwardAuthEnv into buildBaseSpawnEnv
The forwardAuthEnv helper had a single call site inside
buildBaseSpawnEnv. Inlining the forward loop removes a layer of
indirection for a private helper and consolidates the empty-string
guard comment next to the code it documents.
No behavior change. All 72 tests in cowork-path-translation.bats
still pass, including the four #482 regression tests.
Co-Authored-By: Claude <claude@anthropic.com>
* docs(readme): credit @pb3ck for #482 diagnosis
Co-Authored-By: Claude <claude@anthropic.com>
---------
Co-authored-by: Claude <claude@anthropic.com>
XRDP sessions lack GPU acceleration; Electron's default GPU compositing
renders a blank window. Detect XRDP via the $XRDP_SESSION env var and
systemd-logind's session Type, then append --disable-gpu and
--disable-software-rasterizer to the Electron args.
Based on @davidamacey's fix in #320, with the detection hardened: we
use `loginctl show-session -p Type --value` instead of probing for
xrdp-sesman, because that daemon runs on any host with xrdp installed
and would false-positive on local sessions.
Adds tests/launcher-xrdp-detection.bats with 8 cases covering both
positive and negative detection paths, including the XDG_SESSION_ID-
unset and loginctl-nonzero edge cases.
Fixes: #319
Co-authored-by: davidamacey <davidamacey@gmail.com>
Co-authored-by: Claude <claude@anthropic.com>
Follow-up to #453: the daemon still spawns virtiofsd via PATH lookup
(`spawnProcess('virtiofsd', ...)`), so on stock Debian/Ubuntu
(`/usr/libexec/virtiofsd`) and Arch/CachyOS/Manjaro
(`/usr/lib/virtiofsd`) the spawn ENOENTs and KvmBackend silently
falls through to virtio-9p — users who opted into
`COWORK_VM_BACKEND=kvm` and installed virtiofsd get 9p performance
without knowing.
Mirror doctor.sh's `_find_virtiofsd` in JS: probe `COWORK_VM_VIRTIOFSD_BIN`
override, then `which`, then the same fallback list. Pass the resolved
absolute path as argv[0] so the spawn bypasses PATH entirely.
Also:
- Add a `spawnFailed` flag the socket-wait loop checks for early exit
when the async 'error' event fires (e.g. binary removed between
probe and exec) — prevents a 5s stall before 9p fallback.
- Guard `this.virtiofsdProcess.kill()` against the race where the
error handler has already zeroed it.
- Rename doctor.sh's test hook `_COWORK_DOCTOR_VFSD_PATHS` →
`_COWORK_VFSD_PATHS` so doctor and daemon share the same env var
for lock-step test parity (shipped 24h ago in #453, zero external
users).
Verified on CachyOS via a node harness covering 8 scenarios:
PATH hit, fallback hit, fallback ordering, total miss, non-executable
rejection, explicit override wins over PATH, override non-executable
→ null, override missing → null (no fall-through).
All 45 BATS tests still pass after the env-var rename.
Not verifiable locally: Ubuntu `/usr/libexec/virtiofsd` hit (needs an
Ubuntu VM with `qemu-system-common`). Logic is symmetric to the Arch
case that is verified.
Co-authored-by: Claude <claude@anthropic.com>
* fix: detect virtiofsd at off-PATH install locations (#447)
Ubuntu ships virtiofsd at /usr/libexec/virtiofsd (from qemu-system-common)
and Arch/CachyOS/Manjaro at /usr/lib/virtiofsd. Neither is on the default
$PATH, so doctor.sh's `command -v virtiofsd` always returned a false
negative — users would install the package and still see "virtiofsd: not
found" (reported most recently by @zabka in #445, originally flagged by
@jarrodcolburn).
Adds a _find_virtiofsd helper that searches PATH first, then the known
off-PATH install locations:
- /usr/libexec/virtiofsd (Debian/Ubuntu/Fedora/RHEL)
- /usr/lib/qemu/virtiofsd (legacy Debian)
- /usr/lib/virtiofsd (Arch/CachyOS/Manjaro)
Splits virtiofsd out of the KVM tools loop into a dedicated three-branch
check:
[PASS] virtiofsd: found — on PATH
[PASS] virtiofsd: found at <path> (not on PATH) — off-PATH, bwrap default (virtiofsd unused)
[WARN] virtiofsd: found at <path> but not on PATH — off-PATH, COWORK_VM_BACKEND=kvm
(+ info lines about 9p fallback + symlink Fix)
[INFO]/[WARN] virtiofsd: not found — missing (severity ladder unchanged)
The WARN-on-KVM-active branch surfaces that KvmBackend spawns virtiofsd
by PATH name and will silently fall back to virtio-9p (lower performance)
if the binary is only reachable off-PATH — so the user knows a symlink
is needed to actually get virtiofs performance.
Tests: 6 new BATS cases in tests/cowork-bwrap-config.bats exercise the
helper (PATH hit / fallback hit / ordered fallback / total miss /
non-executable skip / default-list regression guard for the Arch path).
All 45 tests pass.
Does not touch cowork-vm-service.js — teaching KvmBackend to probe
these same paths would give Ubuntu KVM users real virtiofs performance
without a symlink, but that's a separate change.
Fixes#447
Co-Authored-By: Claude <claude@anthropic.com>
* style: collapse unnecessary line continuations in virtiofsd check
Simplifier pass — the five backslash-continued `_warn` / `_info`
invocations in the new virtiofsd three-severity block were all under
63 chars after collapsing, well within the project's 80-char
guideline. The continuations were visual noise, not wrap-driven.
Behavior byte-identical. All 45 BATS tests still pass.
Co-Authored-By: Claude <claude@anthropic.com>
---------
Co-authored-by: Claude <claude@anthropic.com>
* fix: diagnose AppArmor userns block on bwrap probe (#351)
Ubuntu 24.04+ ships apparmor_restrict_unprivileged_userns=1 by
default, which blocks the user namespace bwrap needs to start. The
daemon's probe then fails, auto-detect silently falls through to
KVM, and KVM hangs waiting for a rootfs the user hasn't set up —
leaving Cowork stuck in a retry loop with no clear error.
- Classify the probe failure (classifyBwrapProbeError) so the daemon
can distinguish AppArmor/userns blocks from generic failures and
log a pointer to the TROUBLESHOOTING.md remediation.
- Stop falling through to KVM when bwrap is installed but blocked;
drop to host-direct instead so users see a working (if unsandboxed)
Cowork and the reason bwrap didn't engage. Users who actually want
KVM can still set COWORK_VM_BACKEND=kvm.
- Mirror the probe + diagnosis in `--doctor` so misconfigured systems
get the same actionable output without waiting for a daemon log.
- Document the AppArmor profile workaround in TROUBLESHOOTING.md.
- Credit @hfyeh for the diagnosis and profile snippet.
Co-Authored-By: Claude <claude@anthropic.com>
* refactor: simplify PR #434 per cdd-code-simplifier
Drop redundant `-n` guard around the COWORK_VM_BACKEND case in
`--doctor`: the `${VAR,,}` expansion is already safe on an unset var
(no `set -u` in this script) and the `kvm|host` arms simply don't
match an empty string.
Co-Authored-By: Claude <claude@anthropic.com>
---------
Co-authored-by: Claude <claude@anthropic.com>
cleanSpawnArgs only translated --add-dir and --plugin-dir flag pairs.
The Electron app emits permission patterns like
Edit(//sessions/{name}/mnt/.auto-memory/**) and Write(...) inside the
single comma-separated --allowedTools value, and those reached the
spawned `claude` CLI verbatim. Permission rules referencing
non-existent guest paths cannot match the real on-disk locations, so
auto-memory grants silently no-op even after #389 made the underlying
path resolvable and #392 fixed the cwd resolution.
This adds two helpers and wires them into cleanSpawnArgs:
splitToolList(csv):
Paren-aware split so "Bash(npm test, npm build)" is one entry
rather than two. Returns an array of raw entries.
translateEmbeddedGuestPaths(csv, mountMap):
Walks each entry. "Tool" is passed through. "Tool(pattern)" is
translated when the pattern looks like a /sessions/ guest path.
Defensively normalizes leading "//" (the Electron app emits double
slashes via path.join('/', '/sessions/...')). Entries whose mount
cannot be resolved are dropped from the CSV; the flag itself is
kept (a permission rule that can never match is worse than absent).
cleanSpawnArgs now recognizes --allowedTools and --disallowedTools as
"tool-list flags" alongside the existing single-path flags. Single-path
behavior is unchanged.
BATS coverage in tests/cowork-path-translation.bats covers
splitToolList (paren handling, empty/null), translateEmbeddedGuestPaths
(passthrough, double/single-slash translation, drop-on-miss, host-path
passthrough, mcp__ tool names, empty/null), and the cleanSpawnArgs
integration for both new flag types.
Refs: #245 (umbrella), #389 (memory env translation), #392 (cwd fix).
Co-authored-by: Claude Opus 4 <noreply@anthropic.com>
* fix: resolve working directory from primary mount on HostBackend
The Electron app sends `cwd=/sessions/{name}` (a session-root guest
path) for every Cowork session. `resolveWorkDir()` attempts to
translate this via `translateGuestPath()`, but that function's regex
requires `/sessions/{name}/mnt/{mount}/...` — the session root has no
`/mnt/` component, so translation always fails and CWD falls back to
`os.homedir()`.
BwrapBackend avoids this because it overrides `spawn()` and derives CWD
from the primary user mount (first non-dotfile, non-uploads key in
`mountMap`). HostBackend goes through `resolveWorkDir()` which lacked
this fallback.
Add the same primary-mount derivation to `resolveWorkDir()`: when the
CWD is a session-root guest path that `translateGuestPath()` cannot
resolve, find the primary user mount from `mountMap` and use its host
path. Falls back to homedir only when no user mount exists.
Verified with a Node.js test harness simulating the exact spawn
parameters from live session logs — the fix produces the correct
project directory while all edge cases (no user mount, empty mountMap,
host paths, sharedCwdPath precedence) behave correctly.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* test: cover resolveWorkDir primary-mount fallback; extract findPrimaryMount
Adds BATS coverage for the session-root cwd fix and extracts the
primary-mount derivation into a shared findPrimaryMount() helper so
HostBackend's resolveWorkDir() and BwrapBackend.spawn() share one
canonical implementation instead of two copies that can drift.
Tests:
- resolveWorkDir: session-root cwd uses primary user mount
- resolveWorkDir: session-root cwd skips dotfile and uploads mounts
- resolveWorkDir: session-root cwd with no user mount falls back to home
- findPrimaryMount: returns null for null/undefined/empty mountMap
- findPrimaryMount: returns first non-dotfile non-uploads key
- findPrimaryMount: returns null when all mounts are dotfiles or uploads
- findPrimaryMount: insertion order determines primary when multiple exist
The inline test copy of resolveWorkDir is updated to match the new
production logic.
Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
The mountPath() methods in HostBackend, BwrapBackend, and KvmBackend
joined os.homedir() with a root-relative subpath, causing paths like
/home/user/home/user/.config/Claude/... instead of the correct
/home/user/.config/Claude/...
The subpath parameter is encoded as path.relative("/", absolutePath),
making it root-relative. Joining with "/" instead of os.homedir()
produces the correct absolute path.
Fixes#373
Co-Authored-By: Claude <claude@anthropic.com>
https://claude.ai/code/session_01TEWYXVLaKgBfKVkHY47g9M
- Normalize disabledDefaultBinds paths and reject critical mounts at load time
- Add symlink resolution (fs.realpathSync) as defense-in-depth in validateMountPath
- Expand bwrap TWO_ARG_FLAGS/THREE_ARG_FLAGS for forward compatibility
- Add config loading log in BwrapBackend constructor
- Consolidate 4x parser duplication to single invocation in launcher-common.sh
- Remove redundant _doctor_colors call and duplicate restart message
- Decouple tests from production code via require.main guard + module.exports
- Add 6 new tests (symlinks, disabledDefaultBinds validation, extended flags)
Co-Authored-By: Claude <claude@anthropic.com>
Allow users to add/remove BubbleWrap sandbox mount points through a
dedicated Linux config file (~/.config/Claude/claude_desktop_linux_config.json),
separate from the official Claude Desktop config.
- Add validateMountPath(), loadBwrapMountsConfig(), mergeBwrapArgs()
to cowork-vm-service.js
- Integrate config loading in BwrapBackend constructor
- Add _doctor_check_bwrap_mounts() to --doctor diagnostics
- Document coworkBwrapMounts in CONFIGURATION.md
- 33 new tests in cowork-bwrap-config.bats
Security: forbidden paths (/,/proc,/dev,/sys) always rejected,
RW mounts restricted to $HOME, critical mounts non-disableable.
Daemon restart required for config changes.
Fixes#339
Co-Authored-By: Claude <claude@anthropic.com>
- Remove workflow_dispatch trigger (no artifacts on manual dispatch)
- Add nodejs npm to Ubuntu test dependencies
- Add explicit permissions: contents: read to workflow
- Replace echo|grep with [[ ]] pattern matching (4 instances)
- Drop ambiguous 2>&1 from install commands
- Use (( ++ )) arithmetic style in test helpers
Validate deb, rpm, and appimage packages after build in CI.
Tests verify package metadata, file layout, desktop entries,
icons, launcher scripts, asar contents (frame-fix, cowork,
native stub, tray icons), and --doctor smoke tests.
Runs as a reusable workflow with matrix strategy (one job per
format) between build and release jobs, gating releases on
passing artifact validation.
The cowork-vm-service daemon was stripping --plugin-dir and --add-dir
CLI args containing VM guest paths (/sessions/{id}/mnt/{name}/...),
which prevented Claude Code from finding skills and plugins.
The fix translates guest paths to host paths using the additionalMounts
object already present in Electron's spawn params, eliminating the need
for external files like .cowork-mounts.json.
Changes to scripts/cowork-vm-service.js:
- Add translateGuestPath() with path traversal prevention
- Add buildMountMap() merging additionalMounts + mountBinds, rejecting
paths that escape the home directory
- Refactor cleanSpawnArgs() to translate instead of strip guest paths
- Refactor buildSpawnEnv() to translate CLAUDE_CONFIG_DIR
- Refactor resolveWorkDir() to translate guest cwd
- Add resolvePluginRoot() to find plugin root from skills/ subdirectory
- Update _prepareSpawn() to build and store mount map
- Update readFile() to translate guest paths
- Update BwrapBackend.spawn() to bind-mount validated mountMap dirs
- Redact env block from request logs (may contain API keys)
Fixes#265
Co-Authored-By: Claude <claude@anthropic.com>