The plan doc served its purpose through #494 (merge) → #498
(scaffolding) → #502 / #503 / #504 / #506 / #509 / #510 (cutover).
v2.0.5+claude1.3883.0 is the first release through the new pipeline,
verified end-to-end on five distros. #493 is closed.
Removes docs/worker-apt-plan.md and the two architecture-pointer
comments in worker/src/worker.js and worker/wrangler.toml that
referenced it. Both files now carry a short self-contained summary
of what the Worker does and why.
Also corrects worker.js's CDN-hostname reference from
objects.githubusercontent.com (the old name) to release-assets
(current, matches #509's regex fix).
Git history retains the full plan doc for anyone who needs the
design rationale; nothing is actually lost.
Phase 4a-APT cutover (#493, #503) moves binary distribution behind a
Cloudflare Worker at pkg.claude-desktop-debian.dev. The Worker serves
repo metadata directly and 302-redirects .deb/.rpm requests to GitHub
Release assets, which makes the >100 MB .deb push cap irrelevant.
GitHub Pages auto-301s legacy aaddrick.github.io/claude-desktop-debian
URLs to pkg.claude-desktop-debian.dev, but the redirect uses http://
(Pages has no cert for pkg.<domain> — DNS points at Cloudflare, so
Pages can never pass domain verification). apt refuses that scheme
downgrade as a security policy, so existing users' sources.list
silently breaks on the next `apt update`. DNF accepts the downgrade
and keeps working.
Changes:
- README.md: install snippets (APT + DNF) now point at
pkg.claude-desktop-debian.dev directly. New users never touch the
Pages redirect chain.
- README.md: add a "Migrating from the old aaddrick.github.io URL"
section with sed one-liners for existing users + a short background
paragraph explaining why the change was needed.
- .github/workflows/ci.yml: release-notes install snippets (APT + DNF,
both branches) and the generated claude-desktop.repo file's baseurl
and gpgkey all point at pkg.<domain>. Smoke-test chain walkers
deliberately keep starting at github.io (they test the full 3-hop
Pages→Worker→Releases chain for clients that do follow the
downgrade, like curl-without-L and dnf).
Refs #493, #503
v2.0.4 rerun of update-apt-repo made it past hops 0 and 1 (the smoke
test scheme fix in #506 worked — Pages' http:// redirect no longer
trips the chain walker), but failed on hop 2:
Hop 2: 302 .../releases/download/v2.0.4+claude1.3883.0/...deb
-> https://release-assets.githubusercontent.com/...
::error::Hop 2 mismatch: expected https://objects\.githubusercontent\.com/,
got https://release-assets.githubusercontent.com/...
GitHub migrated the Release asset CDN from objects.githubusercontent.com
to release-assets.githubusercontent.com (both have been serving in the
past; release-assets is the current canonical hostname). Accept either
hostname via alternation.
Verified against the actual v2.0.4 Release:
$ curl -Is https://github.com/aaddrick/claude-desktop-debian/releases/download/v2.0.4+claude1.3883.0/claude-desktop_1.3883.0-2.0.4_amd64.deb \
| grep -i location
location: https://release-assets.githubusercontent.com/github-production-release-asset/...
Same fix in three sites:
- .github/workflows/ci.yml (update-apt-repo smoke test)
- .github/workflows/ci.yml (update-dnf-repo smoke test)
- .github/workflows/apt-repo-heartbeat.yml (daily heartbeat)
docs/worker-apt-plan.md has historical references to
objects.githubusercontent.com too; those can be updated in a follow-up
docs sweep — the architectural claim (binary bytes flow direct from
GitHub CDN, never through Cloudflare) is unchanged.
Refs #493, #503
* 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>
* fix(ci): smoke test allows http:// on Pages 301 hop
Phase 4a-APT's first rerun of update-apt-repo succeeded all the way
through strip + push (v2.0.3 metadata is live on gh-pages now), but
the smoke test failed at hop 0:
Hop 0: 301 https://aaddrick.github.io/.../*.deb
-> http://pkg.claude-desktop-debian.dev/.../*.deb
Hop 0 mismatch: expected https://pkg..., got http://pkg...
Pages emits http:// in the Location header because https_enforced is
unsettable on the repo's Pages config: DNS for pkg.<domain> points at
Cloudflare (Worker custom_domain), so Pages can never pass domain
verification to provision its own cert. Cloudflare serves both schemes
for pkg.<domain>, so the http vs https in Pages' redirect is cosmetic
— the chain still terminates correctly.
Relax hop 0's regex in both smoke tests (update-apt-repo,
update-dnf-repo) and the heartbeat workflow to accept https?://.
Later hops stay https-only since GitHub's Release-asset redirects
are always HTTPS.
Failure was the tail-end of run 24836419696's rerun:
https://github.com/aaddrick/claude-desktop-debian/actions/runs/24836419696
Refs #493, #503
* chore: retrigger CI (previous trigger lost to GH flake)
Once the CNAME file is in place on gh-pages (Phase 4a-APT), GitHub
Pages auto-301s all aaddrick.github.io/claude-desktop-debian/* traffic
to pkg.claude-desktop-debian.dev/*. The Worker's origin fetch against
aaddrick.github.io gets 301'd by Pages, the 301 passes through to the
client, the client follows it back to pkg.<domain>, and the Worker
runs again — infinite loop.
Observed immediately after merging #503 and Pages finishing the CNAME
build:
$ curl -I https://pkg.claude-desktop-debian.dev/dists/stable/InRelease
HTTP/2 301
location: http://pkg.claude-desktop-debian.dev/dists/stable/InRelease
x-github-request-id: 3C94:286425:...
x-served-by: cache-yyz4566-YYZ
via: 1.1 varnish
(Scheme-downgrade to http is a separate Pages quirk when
https_enforced=false, which is the case here because DNS points
at Cloudflare, not Pages, so Pages can't provision a cert.)
raw.githubusercontent.com serves the same gh-pages branch content
without Pages' routing layer. All five metadata paths verified to
return 200:
/dists/stable/InRelease
/dists/stable/main/binary-amd64/Packages
/KEY.gpg
/rpm/x86_64/repodata/repomd.xml
/rpm/x86_64/repodata/repomd.xml.asc
Also fixes the deploy-worker.yml post-deploy probe which still
hardcoded pkg-staging. That's what made #503's deploy show as
failed in the Actions UI even though the wrangler deploy itself
succeeded — route bound and Worker live, but the probe was
resolving a hostname wrangler had just removed.
Refs #493, #503
Co-authored-by: Claude <claude@anthropic.com>
Phase 2 container validation passed against
pkg-staging.claude-desktop-debian.dev — APT (debian:stable,
ubuntu:24.04, debian:testing) and DNF (fedora:latest, rockylinux:9)
both install the current pool version via the Worker chain. The one
remaining failure is #500's sha256 mismatch on RPM download, and
PR #502's gh release upload --clobber fix runs on the next release
that reaches update-dnf-repo.
This flip binds the Worker to pkg.claude-desktop-debian.dev. Once
this is deployed, the strip step's liveness probe in update-apt-repo
and update-dnf-repo will start succeeding, stripping .debs/.rpms from
the local pool tree before push — the original #493 blocker.
Pre-merge checklist (manual, outside this PR):
1. Add CNAME file containing pkg.claude-desktop-debian.dev to the
gh-pages branch root (via Pages settings UI or direct push).
2. Wait for GitHub Pages cert provisioning. Typical ~1h; verify in
repo Settings > Pages that the green cert indicator shows.
3. Merge this PR. CI deploys the Worker to the new route via
deploy-worker.yml.
4. Confirm production probe responds:
curl -fsI https://pkg.claude-desktop-debian.dev/dists/stable/InRelease
5. Re-run the failed update-apt-repo + update-dnf-repo jobs from the
v2.0.3+claude1.3883.0 run (gh run rerun 24836419696 --failed) —
this simultaneously validates #500's fix and completes the v2.0.3
release for apt/dnf users.
Rollback: remove the CNAME file from gh-pages, unbind the Worker
route via the Cloudflare dashboard. gh-pages .deb assets from the
pre-strip history still exist and serve directly via github.io.
Refs #493, #500
Co-authored-by: Claude <claude@anthropic.com>
Fix#500: rpmsign --addsign mutates RPMs in place, so the Release
asset uploaded by the release job (unsigned) diverged from the
signed copy in gh-pages. The Worker redirects to the Release asset,
so dnf saw a sha256 that didn't match repodata. Re-upload the signed
RPMs to the Release via gh release upload --clobber after signing.
Fix#501: The imported GPG keyring contains two keys; reprepro signs
InRelease with one and rpmsign signs repomd.xml.asc with the other,
but the published KEY.gpg only contained one of them. Strict clients
like rockylinux:9 rejected repo metadata with "Bad GPG signature".
Export the full keyring (all public keys) to KEY.gpg so both
signatures verify.
Validation (per issue reproduction steps):
- Re-run update-dnf-repo on a test tag
- sha256 of gh-pages RPM must match the Release asset download
- fedora:latest dnf install should succeed (was "All mirrors tried")
- rockylinux:9 dnf makecache should succeed (was "Bad GPG signature")
Co-authored-by: Claude <claude@anthropic.com>
* feat: APT/DNF Worker scaffolding (#493)
Adds the implementation scaffolding for the Cloudflare Worker that
fronts the APT/DNF repo, per docs/worker-apt-plan.md.
New files:
- worker/src/worker.js: redirects /pool/.../*.deb and /rpm/*/*.rpm
to GitHub Release assets via 302; passes metadata through to
the gh-pages origin
- worker/wrangler.toml: bound to pkg-staging.claude-desktop-debian.dev
initially; Phase 4a switches to pkg.claude-desktop-debian.dev
- .github/workflows/deploy-worker.yml: deploys Worker on worker/**
push, post-deploy probe verifies route bound + Worker responding
- .github/workflows/apt-repo-heartbeat.yml: daily cron, deb+rpm
matrix, walks ordered redirect chain + size match against Releases
asset, opens format-specific tracking issue on failure (auto-close
on recovery), gates on Worker liveness (skips silently before
Phase 4a)
Modified:
- .github/workflows/ci.yml: gated strip step + ordered-chain smoke
test added to update-apt-repo and update-dnf-repo; the destructive
strip only fires when the production Worker probe succeeds, so this
PR can land before Phase 4a without affecting current behavior
- docs/worker-apt-plan.md: bake in real domain values, mark Decisions
table entries as concrete, fix Cloudflare API token permissions
list (current names: Workers Scripts Edit, Account Settings Read,
Workers Routes Edit; previous "Zone:Zone:Read" name no longer
matches the dropdown)
Pre-Phase-4a behavior: the strip step's liveness probe targets the
production hostname which doesn't exist yet, so it always skips and
.debs/.rpms are pushed to gh-pages exactly as today. Smoke tests skip
on the same gate. Heartbeat workflow's gate skips before the Worker
is live. Nothing destructive happens until Phase 4a explicitly cuts
the Worker over to production.
Co-Authored-By: Claude <claude@anthropic.com>
* refactor: simplify worker scaffolding per cdd-code-simplifier review
- worker.js: use named capture group `asset` instead of opaque `m[1]`
positional reference; inline single-use `tagFor()` helper; demote
unused `arch` capture to non-capturing group.
- ci.yml: hoist `WORKER_DOMAIN` from per-step env to job-level env in
both `update-apt-repo` and `update-dnf-repo` (matches the pattern
already used in `apt-repo-heartbeat.yml`).
- apt-repo-heartbeat.yml: use github-script's native `context.serverUrl`
/ `context.runId` instead of reconstructing from process.env; spread
`...context.repo` instead of repeating owner/repo on every API call;
destructure `{ data: open }` to flatten `open.data` references.
All changes preserve behaviour. The contrarian-fix mechanisms (positive
Worker liveness probe gating the strip step, hop-by-hop ordered chain
walk in smoke tests) are unchanged. APT/DNF strip + smoke pairs remain
in-place per reviewer-readability preference.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds docs/worker-apt-plan.md, the implementation plan for fixing CI
run 24811974733 where update-apt-repo was rejected by GitHub's 100 MB
per-file push cap (.deb is 130 MB after upstream growth + ion-dist).
Approach: Cloudflare Worker on a custom domain fronts existing GitHub
Pages metadata and 302-redirects binary requests to GitHub Release
assets (which CI already publishes successfully). Existing user
sources.list entries preserved via Pages auto-301 from *.github.io to
the custom domain.
Plan went through two contrarian review rounds. Replaces the prior
gh-pages-split-plan.md draft (split-into-separate-repo approach is
no longer needed once .debs stop being committed to gh-pages).
Co-authored-by: Claude <claude@anthropic.com>
The Claude Desktop app registers a custom app:// protocol handler rooted
at process.resourcesPath/ion-dist — that directory holds the static SPA
assets for internal windows like Third-Party Inference setup, Connectors
config, etc. The Windows installer ships ion-dist under lib/net45/resources
but scripts/staging/cowork-resources.sh never copied it into the electron
resources dest, so every app://localhost/* request fell through to the
static file handler's index.html fallback, which also failed because the
whole directory was missing.
Net effect: the Third-Party Inference setup window (Developer → Configure
Third-Party Inference…) opened as a blank window with
ERR_FILE_NOT_FOUND / ERR_UNEXPECTED on loadURL.
Add an ion-dist copy step to copy_cowork_resources() matching the
existing smol-bin / plugin-shim pattern (warn-and-continue on absence),
and a matching install stanza in nix/claude-desktop.nix so NixOS users
get the fix too — without the Nix hunk, ion-dist lands in the
nix-resources/ sentinel but the installPhase doesn't cherry-pick it.
Verified end-to-end:
- Fresh build ./build.sh --build appimage picks up ion-dist from the
1.3883.0 Windows installer (84 MB uncompressed, ~42 MB delta in the
AppImage after squashfs).
- Live install on CachyOS daily-driver; Developer → Configure Third-Party
Inference… now renders the full 6-tab config UI (Connection / Sandbox
& workspace / Connectors & extensions / Telemetry & updates / Usage
limits / Plugins & skills / Egress Requirements) per the upstream
docs at support.claude.com/en/articles/14680741.
- Logs clean of ERR_UNEXPECTED / ERR_FILE_NOT_FOUND on setup-desktop-3p.
Fixes#488
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.
When the launcher runs in native Wayland mode (CLAUDE_USE_WAYLAND=1 or
forced by compositor), it sets the correct Electron ozone flags but does
not override GDK_BACKEND. A system-wide or session-level GDK_BACKEND=x11
then silently wins, causing GTK to connect via XWayland and producing
blurry rendering on HiDPI displays.
Export GDK_BACKEND=wayland in the native Wayland branch of
build_electron_args() so the ozone flags and GDK backend stay in sync.
Replaces the globalShortcut registration in frame-fix-wrapper.js with a
per-window webContents 'before-input-event' handler. The previous global
grab stole Ctrl+Q from every app on the system and — on non-QWERTY
layouts — also swallowed whatever keysym sits at the physical "Q"
position (Ctrl+A on AZERTY be,us).
The new handler only fires when Claude has keyboard focus, so other apps
keep their Ctrl+Q. Menu-accelerator coverage for the hidden menu bar
case (original #321 motivation) is preserved because webContents
intercepts the key directly, independent of menu visibility.
Fixes: #399Fixes: #474
Co-authored-by: Claude <claude@anthropic.com>
* 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>
Reporter on #481 pasted the deb package version `claude-desktop
1.3561.0-2.0.0`. The classifier extracted `1.3561.0-2.0.0` verbatim,
and the naive `claimed != CLAUDE_DESKTOP_VERSION` string compare
flagged drift against `1.3561.0`. The issue is on the current
release — no drift should fire.
Fix normalizes both sides: strip a leading `v`, then strip anything
from the first `-` or space onward. Handles:
- `1.3561.0-2.0.0` → `1.3561.0` (deb package: upstream-REPO_VERSION)
- `v1.3561.0` → `1.3561.0` (copy-paste with prefix)
- `1.3561.0 stable` → `1.3561.0` (whitespace-separated qualifier)
- `1.3561.0` → `1.3561.0` (bare upstream, unchanged)
Same normalization applied to CURRENT_VERSION for symmetry, even
though the repo variable is always the bare upstream semver — keeps
the compare resilient if that ever changes.
Fixes the false drift banner on #481 and prevents the same shape
from tripping on any future issue where a reporter pastes their
`dpkg -l | grep claude` output or AppImage filename.
Co-authored-by: Claude <claude@anthropic.com>
The README was drafted as a design spec before implementation. Now
that the pipeline is live and the design has been validated end-to-
end, bring the doc into agreement with the code and retire the two
companion files.
README updates:
- Intro: state the production trigger (`issues: [opened]`) and the
workflow_dispatch fallback; note v1 is manual-only
- Stage 7 table: reorder by actual priority (drift is no longer a
top-of-gate veto); drift section rewritten to describe the banner-
and-candidates-modifier behavior landed in PR #476
- Stage 8a rendered-output example: show the conditional drift
banner + drift-bridge candidates block that actually render
- Stage 8b reason enum: add `reference-source unavailable` that was
missing from the list
- Rollout posture: describe the cutover as completed, not deferred
- Implementation layout: drop "during rollout" qualifier; add
helper-scripts row (validate.sh / drift-bridge.sh /
suspicious-input-scan.sh / extract-json.py)
- Artifacts list: full set with 14-day retention, not just the
original four
- Reasons.json SSOT pointer: actual path `.claude/scripts/reasons.json`
instead of the aspirational `lib/templates/reasons.json`
- Potential future improvements: drop "Cutover to issues:[opened]"
subsection (done)
- Clean up "v1" usage where it means "first version of the pipeline"
(confusable with legacy v1 workflow)
Deleted:
- docs/issue-triage/implementation-plan.md — phased build sequence
is complete; commit history preserves the record
- docs/issue-triage/research-trail.md — design-pass sources are cited
inline in the README where needed
Workflow banner updated to drop the `implementation-plan.md` pointer.
Co-authored-by: Claude <claude@anthropic.com>
Three changes bundled because they land together as the cutover:
1. **v2 `issues: [opened]` trigger enabled.** Workflow now fires
automatically on new issues in addition to the existing
workflow_dispatch path. `run-name`, `concurrency.group`, and the
gate step's ISSUE_NUMBER all resolve via
`github.event.issue.number || inputs.issue_number` so both
trigger paths work. The existing `inputs.dry_run != true` gates
on label/comment application — under an issues trigger that
expression is empty ≠ true, so production posts/labels land.
2. **v1 `issues` trigger removed.** `issue-triage.yml` keeps
`workflow_dispatch` for manual fallback (maintainer can still
fire it if v2 is paused or rolled back), but no longer runs
automatically. v1's `run-name`/concurrency dropped the now-dead
`github.event.issue.number` fallback.
3. **Investigate timeout 600s → 1200s.** Bumped after two
consecutive timeouts on #311 during Phase 4 + drift-as-banner
verification. The investigator needs more tool-call budget on
complex issues. Review step stays at 600s — it runs without
tool access and has never timed out.
Rollback: revert this commit to restore v1's automatic trigger;
v2's `issues:` block goes back to workflow_dispatch-only in the
same operation.
Co-authored-by: Claude <claude@anthropic.com>
Introduces docs/DECISIONS.md as a TPM-style direction log for decisions
that shape what this project does and does not do. Decisions are stable,
dated, and revisited by opening an issue that cites the decision ID —
they're not deleted or silently reversed.
The first entry, D-001, records the decision that auto-update flows
through platform package managers (APT / DNF / AUR) and AppImageUpdate
only — no in-tree cron updaters. Captures the rationale, the accepted
trade-offs (AppImage users without a supported-distro repo have no
first-party auto-update path), and the alternatives considered.
Context: PR #320 proposed cron-driven auto-update scripts; the XRDP
portion was salvaged into PR #475, and this entry closes the loop on
why the auto-update portion was declined at the direction level.
Co-authored-by: Claude <claude@anthropic.com>
Post-Phase 4 verification showed two issues (#311, #448) where the
pipeline successfully produced valuable findings against current
code, but the top-of-gate drift veto routed them to 8b drift-only
and the findings were discarded. The reporter cited an older version
(1.1.7464 on #311), the investigation ran cleanly on current
(1.3.5610), and the reviewer approved the findings — yet the comment
still read "couldn't reach a confident read."
This change keeps drift detection and keeps the drift-bridge sweep.
What changes is Stage 7: drift is no longer at the top of the gate.
When drift is detected and 8a or 8c would render cleanly, the
renderer prepends a drift banner (⚠ You reported this on X; bot
investigated on Y. Citations may still apply.) and appends the
drift-bridge-candidates block at the bottom. The finding citations
stand — they describe current code in hypothesis voice, which is
what the reader can verify against their own checkout.
When drift is detected and the pipeline would otherwise route to 8b
for any other reason (fetch-failure, invest-failure, review-failure,
no-findings, low-confidence), the reason is overridden to
`version-drift`. Drift-bridge candidates give the maintainer a more
actionable signal than "no findings" on its own.
Reviewer prompt gains one rubric addition: downgrade-confidence when
the cited surface clearly post-dates the reporter's version. Catches
the case where a finding is valid on current but wouldn't reproduce
on what the reporter saw. Doesn't degrade findings indiscriminately
— only when the reviewer can see version-specific evidence.
Confirmed-duplicate routing wins over the drift-reason override
(explicit exclusion in the override clause) because `triage:
duplicate` is still the more specific read.
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>
* feat(triage): Phase 4 sub-PRs 3+4 — regression_of + edit-during-triage
Bundles the two remaining Phase 4 sub-phases. Both are small workflow
additions that build on infrastructure already in place: the Phase 1
input snapshot (updated_at captured at Stage 1) and the Phase 1
classify.json's regression_of field.
regression_of end-to-end (Stage 3b + Stage 4 + Stage 6)
- New step `Validate regression_of` between drift-check and fetch.
Runs only when classify set regression_of to non-null.
- Validation: PR exists in this repo; PR is merged; PR's mergedAt
precedes issue's createdAt. Any failure clears to null with a
logged note and the issue proceeds as a regular bug.
- Valid regression → `gh pr diff` fetched (capped at 4000 lines) and
inlined into the investigate prompt as primary context. Tells the
investigator to start the search in the PR's changed files.
- Same diff inlined into the review prompt, wrapped as pipeline_data,
so the reviewer can check whether findings land inside the named
PR's changed files.
- Handles the spec's "cleared to null with logged note" requirement
for upstream Electron PRs that aren't in this repo.
Edit-during-triage detection (Stage 8 post-processor)
- New step between 8a/8c post-processors and Apply labels. Runs for
every variant.
- Re-fetches issue.updated_at live and compares against the Stage 1
input_snapshot.updated_at.
- On mismatch: appends a `⚠ This issue was edited after triage
began. ...` disclaimer to the rendered comment, pointing at
input_snapshot.json as the audit trail.
- Catches inject-then-delete attacks (inject instructions, wait for
bot, delete before a human reads) and honest mid-triage edits
that would make the comment stale.
Step summary gains `regression_of validated` row.
With this PR, Phase 4 is complete: 8c enhancement-design, suspicious-
input tells, regression_of, edit-during-triage detection are all
live. All terminal paths (bug / enhancement / question / duplicate /
needs-info / not-actionable / suspicious) flow through the pipeline
end-to-end per spec.
Co-Authored-By: Claude <claude@anthropic.com>
* docs(triage): correct stale sort -u reference in date-compare comment
The comment above the ISO 8601 date check referenced `sort -u`,
which isn't used in the code. Rewrite to describe what the code
actually does: `[[ > ]]` on the raw timestamp strings, which is
valid because ISO 8601 sorts lexicographically as chronologically.
Also re-orient the prose around the invalid case (mergedAt AFTER
createdAt), matching the branch that the following `if` takes.
Co-Authored-By: Claude <claude@anthropic.com>
---------
Co-authored-by: Claude <claude@anthropic.com>
* feat(triage): Phase 4 sub-PR 2 — suspicious-input tells
Adds a conservative Stage 2a tripwire that scans the raw issue body
and title for prompt-injection tells before any LLM call. A match
short-circuits routing to 8b with reason
`suspicious-input — manual review`, no Sonnet invocation.
The scan is the front-line filter; the actual injection mitigations
(wrap-as-data, fresh-context reviewer, schema-constrained output)
remain in place for everything that doesn't trip. The two layers are
complementary: the scan catches the obvious attempts cheaply, the
downstream defenses protect against the clever ones.
Taxonomy
- taxonomies/suspicious-input-tells.json — eight tells with regex
patterns and rationale:
- ignore-prior-instructions: classic opener
- system-prompt-leak: exfiltration attempts
- role-override: "you are now a different…"
- forget-instructions: variation of ignore-prior
- developer-mode: named jailbreaks (DAN, etc.)
- instruction-injection-sysrole: chat-template tokens
- long-base64-block: 200+ contiguous base64 chars
- unicode-tag-sequence: U+E0000-E007F invisibles
Scanner
- scripts/triage/suspicious-input-scan.sh — pure bash, PCRE via
grep -Pzi, writes suspicious-input.json with matched_tells[].
Uses the same taxonomy-as-data pattern as reasons.json and
label-blocklist.json.
Workflow
- Stage 2a step runs between input snapshot and classify, outputs
`suspicious` boolean
- Classify + doublecheck both `if:`-gated so they skip on a hit
- Decide route takes suspicious first, before the doublecheck
disagreement check — a tripped tell defers deterministically
- Step summary shows the suspicious flag
Co-Authored-By: Claude <claude@anthropic.com>
* refactor(triage): drop dead null-string guards in suspicious-input scan
jq -r '.body // ""' already returns an empty string for JSON null or a
missing field, so the subsequent `[[ "${body}" == "null" ]]` guards only
fire when a reporter's body is the literal four-character string "null"
— which isn't an injection signal and matches no tell. The comment
describing the guards was also wrong about jq's behavior. Remove both
guards and correct the comment.
Also fix a misleading comment about `|| true` (which isn't in the code)
and collapse the 4-line `suspicious` boolean derivation into a single
`jq 'length > 0'`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(triage): Phase 4 sub-PR 1 — Stage 8c enhancement-design variant
Adds the third Stage 8 template variant. Previously, enhancement-
classified issues fell through to 8b human-deferral; now they run
through the investigate pipeline with enhancement-specific prompts
and render a lightweight acknowledgment + existing-surface citations
+ design-review questions from a fixed taxonomy.
Prompts and schemas
- taxonomies/enhancement-design-questions.json — six fixed IDs:
config-schema-stability, backward-compat, security-surface,
test-coverage, observability, packaging-format. Each carries a
concrete question the renderer surfaces verbatim.
- schemas/comment-enhancement.json — structured output: 1-sentence
acknowledgment_line, 0-3 existing_surfaces (each with file:line),
1-3 design_question_ids (enum-matched against the taxonomy).
- prompts/comment-enhancement.txt — drafter prompt, hypothesis
voice, rules of thumb for picking design questions.
- prompts/investigate-enhancement.txt — investigate variant. Same
schema, but claim_type=absence is banned (by definition the
enhancement's capability is absent; restating is redundant and
tips into design-prescription). Findings must cite existing code
the enhancement would touch.
- prompts/review-enhancement.txt — reviewer rubric reframed from
"is this defect claim correct?" to "is this an existing surface
the enhancement would actually touch?" Reject leans on
real-but-irrelevant surfaces, since those actively misdirect.
Workflow
- Route decision: enhancement now enters the investigate path
alongside bug and duplicate (route renamed `investigate`). Both
the investigate step and the review step pick the enhancement-
variant prompt when classification == enhancement.
- Decision gate: new enhancement branch slotted between
invest-failure and no-findings. 8c fires when review succeeded
(any kept count, including 0) OR when findings_passed was 0 and
the review step was skipped by design — the design questions
carry the comment alone.
- Stage 8c render: bash cross-joins design_question_ids against
the taxonomy; a missing lookup errors loudly rather than
silently dropping.
- 8c post-processor: 350-word cap per spec; trims the last
existing_surfaces bullet when over cap.
- Apply labels: 8c variant → `triage: investigated` +
`enhancement` class label.
Deferred to later Phase 4 sub-PRs: suspicious-input tells,
regression_of end-to-end diff fetch, edit-during-triage detection.
Co-Authored-By: Claude <claude@anthropic.com>
* refactor(triage): reuse classify step output instead of re-parsing classification.json
Drops two redundant `jq -r '.classification' /tmp/triage/classification.json`
calls in the investigate + review steps; both now read the value via a
`CLASSIFICATION_NAME` env var sourced from `steps.classify.outputs.classification`.
Matches the `Decide comment variant` step's existing pattern for
reading classify state, so the three call sites converge on one idiom.
No behavior change — the prompt-selection conditional reads the same
value; just fewer forks of jq.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Post-rename verification on #448, #449, and #424 showed the first-pass
classifier now leans `enhancement` on all three, while the doublecheck
correctly reads them as `bug`. The disagreement failsafe defers each
to human review, which is safe but wastes the doublecheck as a
classification-recovery mechanism rather than a verification one.
Root cause: the "broken expectation wins" rule lives only in the
doublecheck prompt. First pass sees `enhancement` framing ("breaks X",
"should support Y") and weights it as an enhancement request. Adding
the rule to the primary classify prompt brings first-pass behavior in
line with doublecheck expectations.
Explicit examples added from the test set (minimize-to-tray, APT pool
regression, CTRL+C) so future calibration drift is easier to notice.
Co-authored-by: Claude <claude@anthropic.com>
Aligns the v2 classifier vocabulary with the repo's GitHub label
vocabulary. Previously `classification=feature` was mapped to label
`enhancement` at Stage 9 — a redundant indirection that also caused
miscalibration on defects framed as enhancement-shaped asks (e.g.
#448 "breaks in-app schedulers and 'minimize to tray' expectation"
classified as feature + ambiguous when the maintainer read is bug).
Changes:
- classify.json enum: feature → enhancement
- classify-doublecheck-bugfeature.{json,txt} → classify-doublecheck-bug-vs-enhancement.{json,txt}
- Doublecheck rubric tightened: added "breaks X" / "stopped working"
as explicit bug signals and a rule that a broken expectation wins
over enhancement-shaped framing when both are present. Reduces the
chance of #448-shaped defects routing to the ambiguous bucket.
- investigate.txt absence-claim ban: "feature X is missing" →
"capability X is missing"
- reasons.json: "ambiguous bug/feature classification" →
"ambiguous bug/enhancement classification"
- Workflow: doublecheck step renamed, classification checks updated,
class_label map collapsed to direct (no more feature→enhancement
remap).
- docs/issue-triage/{README.md,implementation-plan.md}: vocabulary
updated throughout (~47 occurrences). 8c variant renamed
Feature-design → Enhancement-design. Planned Phase 4 file names
(comment-enhancement.json, enhancement-design-questions.json)
follow suit.
Kept as-is:
- `.github/ISSUE_TEMPLATE/feature_request.yml` filename — preserves
the GitHub convention reporters recognize on the issue-chooser page;
classifier buckets issues filed through it as `enhancement`.
- v1 `issue-triage.yml` + `triage-classify.json` — untouched; v1 is
slated for replacement and doesn't gain from this rename.
No behavioral change at runtime beyond the rubric tightening; the
rename collapses an indirection rather than adding logic.
Co-authored-by: Claude <claude@anthropic.com>
Gives @sabiut review ownership of /tests/, /scripts/doctor.sh, and the
test-artifacts + test-flags workflows. Shared review with @aaddrick on
/docs/TROUBLESHOOTING.md and /.github/workflows/shellcheck.yml.
Cowork override at the bottom of the file still wins for
/tests/cowork-*.bats per last-match-wins.
Announcement: #467
Co-authored-by: Claude <claude@anthropic.com>
* feat(triage): Phase 3 — Stage 6 adversarial reviewer + duplicate gate
Adds a fresh-context reviewer between mechanical validation (Stage 5)
and the decision gate (Stage 7). The reviewer steel-mans each surviving
finding, commits to a counter-reading, runs closed-world checks on
identifier claims, and emits approve / downgrade-confidence / reject
with structured rationale. It also rates each cited related_issue and
the duplicate_of target (exact / related / unrelated).
Stage 7 now gates on reviewer verdicts. approve keeps a finding at full
confidence; downgrade-confidence keeps it but subtracts 1 from its
contribution to the avg-confidence threshold (floor 0.5); reject drops
it. A new duplicate gate (between fetch-failure and invest-failure in
the priority table) fires when classification == duplicate and the
reviewer rated duplicate_of exact or related — routing the issue to 8b
with 'likely-duplicate-of-#N' as reason and 'triage: duplicate' as
label. An 'unrelated' rating discards the duplicate claim and the
remaining gates apply to the regular investigation output.
- schemas/review.json — reviewer verdict schema, per-finding rationale
required, closed_world_check object for identifier claims, ratings
for related_issues and duplicate_of
- prompts/review.txt — adversarial-reviewer prompt per spec §6; input
is source excerpts + claim + closed_world_options + cited-issue
bodies + duplicate_of body, wrapped as untrusted data; excludes
draft comment, free-form reasoning, and voice instructions
- Workflow: fetch duplicate_of body (inline step), Stage 6 review
call (schema-constrained, no tool access, timeout 600s,
--max-budget-usd 1.50, extract-json fallback on prose), reviewer-
aware filter step, expanded decision gate, triage: duplicate label
path with class inheritance from the target issue (PR #459 item 8),
<pipeline_data> wrappers on 8a-render inlined JSON (PR #459 item 3)
- Route duplicates through investigate pipeline so Stage 5 + Stage 6
can rate the target (previously deferred straight to 8b)
See docs/issue-triage/{README.md §6-§7, implementation-plan.md §Phase 3}.
Co-Authored-By: Claude <claude@anthropic.com>
* refactor(triage): simplify Phase 3 verdict summary step
Two small cleanups in the Stage 6 / "Apply reviewer verdicts" plumbing
that don't touch load-bearing behavior (errexit guards, --slurpfile
cross-join, schema fallback, gate priority, prompt-injection wrappers
all preserved):
* Drop the unused dup_num step output — no consumer references
steps.dup_fetch.outputs.dup_num; Resolve reason text reads
.duplicate_of directly from classification.json.
* Collapse the dup_rating jq filter to a single-line
.duplicate_of_rating.rating // "none" — jq already treats
null.rating as null, so the explicit if/else was just ceremony.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Re-dispatch of #394 showed the full drift-routing path works end-to-
end except for the post-processor word-cap: base 8b comment is ~50
words, drift-bridge-candidates block adds ~130 words for 10 bullets,
privacy note another ~30 when the reporter is first-time. Actual was
189 words vs 150 cap.
Spec §8b note already flagged this: "Verify length is under 150 words
(account for optional drift-bridge-candidates block)." The parenthetical
acknowledged the block expands the comment, but the original 150 was
the base-comment budget and was never adjusted when the drift-bridge
extension landed in Phase 2.
300 covers the observed worst case (~190) with headroom for edge cases
(long PR titles, longer commit subjects, future drift-bridge output
growth) while still bounding the comment at something scannable.
Capping the drift-bridge render at N entries is a separate concern —
deferred in favor of raising the limit first.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Re-dispatch of #394 confirmed the 300s timeout bounds the step, but
also exposed a second bug: the step failed with exit 124 instead of
falling through to 8b gracefully. Downstream steps (Decide / Render /
Label / Post) were all skipped, and the raw/payload/stderr archives
that the earlier hardening created were never written because the
shell aborted at the assignment before `printf > investigate-raw.json`
could run.
Root cause: GHA's default shell is `bash -e {0}` (errexit). With
errexit on, a failing command substitution:
raw=$(timeout 300s claude -p ...)
propagates the exit code and aborts the script BEFORE `claude_exit=$?`
runs. My prior assumption that assignments were exempt from errexit
under `bash -e` was wrong in this shell configuration.
## Fix
Use the if-form, which is the only reliable way to catch a failing
command substitution under `bash -e`:
if raw=$(timeout 600s claude -p ... 2>log); then
claude_exit=0
else
claude_exit=$?
fi
A timeout (exit 124) or other CLI failure now sets `claude_exit`,
writes the archived artifacts, and falls through to 8b with a
specific warning — exactly the graceful path the earlier PR intended
but errexit short-circuited.
## Also bumped timeout 300s → 600s
The original 300s was chosen to be "typical investigate runtime + a
bit." Observed times: #424 ran 218s, #442 ran 220s — so 300s left
almost no headroom. Doubling to 600s gives room for complex issues
to converge while still being short of the ~9-minute hang that
motivated the timeout in the first place.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The investigate call was the only Sonnet invocation in v2 without
`--json-schema`. After the parser hardening in #461, re-dispatched
runs produced valid JSON — but with fields omitted and creative
top-level wrappers. The prompt-described schema isn't enforced
without the flag, and the model was using the freedom.
## What changed
Add `--json-schema "${schema}"` where `schema=$(cat
.claude/scripts/schemas/investigate.json)`, matching the classify
and doublecheck pattern.
Output parsing prefers the CLI-validated `.structured_output` field
(populated when schema fit cleanly), falling back to the existing
`.result` + `extract-json.py` + shape-check path for the case where
the CLI returns prose on schema miss. The hardened extraction from
#461 stays in place as the safety net.
## Why post-hoc still helps
Per Claude Code CLI docs (and confirmed via the claude-code-guide
research), `--json-schema` applies validation after the agent loop
ends — not at generation time. That's weaker than the Agent SDK's
constrained decoding, but still catches the specific failures seen
in the re-dispatch of #424 and #442:
- Top-level `pattern_sweep` and `proposed_anchors` omitted
- Per-finding `confidence` / `line_end` returned as null (violates
required enum / integer)
- Extra top-level fields like `summary`, `classification`,
`investigation_id`
If post-hoc validation isn't enough, the next escalation is the
Agent SDK (constrained decoding via grammar compilation).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three failure modes surfaced in the first round of dispatches against
real issues, all in the Stage 4 Investigate step:
- #394 hung for 9 min (the Claude CLI wedged; no per-call timeout);
user had to cancel manually. Step log was silent because
`2>/dev/null` swallowed stderr.
- #424 and #442 both ran to CLI completion but the payload's jq
presence-check rejected the output. Raw response wasn't archived,
so the specific rejection cause was unknowable post-hoc.
## Fix
- `timeout 300s claude -p ...` — bounds the step at 5 min; exit 124
routes to 8b no-findings gracefully via the existing warning branch.
- `2>/tmp/triage/investigate-stderr.log` instead of `2>/dev/null` —
CLI diagnostics ride along in the run's uploaded artifact bundle,
available for post-mortem without a re-dispatch.
- Raw CLI response archived as `investigate-raw.json` before any
parsing. Extracted payload archived as `investigate-payload.txt`
before schema checks. Schema-reject no longer loses the evidence.
- Fence-strip + jq-presence-check replaced with
`.claude/scripts/triage/extract-json.py`, which uses
`json.JSONDecoder.raw_decode` to handle leading OR trailing prose
around the JSON body. Addresses PR #459 review item 6.
- The shape check now verifies each of the four required fields is
an `array`, not just present — `{"findings": "oops"}` would pass
presence and explode downstream. Addresses PR #459 review item 7.
## Testing
`extract-json.py` exercised locally against: bare JSON, leading
prose, trailing prose, fence-wrapped JSON, pure prose (exit 1),
malformed JSON (exit 2). All cases produce the expected output or
exit code.
`actionlint -shellcheck` clean on the workflow.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a dry_run dispatch input so the pipeline can be validated against
real issues without writing to the repo. Also folds in three items
from the #459 code review that are easier to ship before the first
round of dispatches than after.
## dry_run
- New boolean input on `workflow_dispatch` (default false)
- Guards `Apply labels` and `Post comment` steps
- Step summary shows a ⚠ banner + a "Dry run" row when enabled
- Artifacts still upload, so the rendered `comment.md` is inspectable
## Review fixups (from PR #459 review)
1. **Decision gate priority.** Spec §7 puts version drift ahead of
fetch failure; implementation had them reversed. When both fire,
`version-drift` is the more specific signal and is the only path
that hands the maintainer drift-bridge candidates. Swapped.
2. **Issue titles wrapped as untrusted.** `<issue_title>` now carries
`source="reporter, untrusted"` in all three prompt assemblies
(classify / doublecheck / investigate). Instruction-as-data
directive in each prompt updated to name both `<issue_title>` and
`<issue_body>`. Reporter-controlled title injection surface closed.
5. **`drift-bridge.sh` version search is literal.** `--fixed-strings`
added to `git log --grep` so `1.3.23` doesn't match `1x3y23`.
Items 3, 4, 6-9 from the review are deferred to Phase 3 (adversarial
reviewer) per the review's own scoping.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Directory scaffolding + skeleton workflow + issue templates. No live
behavior — v2 remains workflow_dispatch-only with `permissions: {}` and
a single job that echoes the issue number. v1 (`issue-triage.yml`) is
untouched.
Per docs/issue-triage/implementation-plan.md Phase 0:
- `.github/workflows/issue-triage-v2.yml` — skeleton workflow
- `.github/ISSUE_TEMPLATE/{config,bug_report,feature_request}.yml` —
shapes input for the Stage 2 classifier and Stage 4 investigator;
privacy disclosure in a non-editable markdown info block
- `.claude/scripts/prompts/.gitkeep` — prompts land per-phase
- `.claude/scripts/taxonomies/label-blocklist.json` — Stage 9 suggested-
label gating (wontfix, invalid, duplicate, help wanted, good first
issue); additional taxonomies land in Phase 4
- `.claude/scripts/reasons.json` — Stage 8b deferral-reason SSOT
consumed by the renderer and post-processor (six entries)
- README Privacy section — keeps disclosure text discoverable without
filing an issue; matches the templates' info block
Exit criteria: dispatch against any issue number prints correctly; no
API calls, no comments, no labels; `bug_report.yml` / `feature_request
.yml` render cleanly with the privacy block.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the issue triage pipeline design under docs/issue-triage/:
- README.md — base pipeline spec
- implementation-plan.md — stage-by-stage plan
- research-trail.md — references that informed the design
Replaces the original single-file docs/issue-triage.md that was
reverted from main in f829d3b. Squash of 28 drafting commits from
the prior docs/triage-pipeline-design branch (backup at
backup/docs-triage-pipeline-design-pre-rebase).
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>
Moves run_doctor and its 9 internal helpers out of launcher-common.sh
(~670 lines) into their own scripts/doctor.sh. launcher-common.sh
now sources doctor.sh via a BASH_SOURCE-relative path, so any consumer
still gets the run_doctor entry point without needing to know about
the split.
Rationale: the testing / release-quality role concerns itself with
--doctor, and giving that subsystem its own file lets CODEOWNERS
scope it independently of the rest of launcher-common (display
detection, cleanup handlers, electron env) which remain in aaddrick's
domain.
Each packaging target now installs doctor.sh alongside launcher-common.sh:
scripts/packaging/appimage.sh → /usr/lib/claude-desktop/{launcher-common,doctor}.sh
scripts/packaging/deb.sh → /usr/lib/<pkg>/{launcher-common,doctor}.sh
scripts/packaging/rpm.sh → /usr/lib/<pkg>/{launcher-common,doctor}.sh
nix/claude-desktop.nix → $out/lib/claude-desktop/{launcher-common,doctor}.sh
Pure-move refactor. Function bodies byte-identical to the pre-split
launcher-common.sh content. Verified: `source launcher-common.sh` still
defines all 19 previous functions (9 launcher + 10 doctor); a live
run_doctor invocation produces the same output as before.
Co-Authored-By: Claude <claude@anthropic.com>
Updates agent definitions, learnings, CLAUDE.md, and BUILDING.md so
path references point at the new module files instead of the old
monolithic build.sh.
Agent definitions:
.claude/agents/issue-triage.md — table of per-category
investigation paths now points at scripts/patches/*.sh and
scripts/packaging/*.sh instead of "build.sh (search patch_X)".
.claude/agents/electron-linux-specialist.md — patching-functions
table now includes each function's file location; directory tree
illustration reflects the new scripts/ layout.
Documentation:
CLAUDE.md — "Working with Minified
JavaScript" section points at scripts/patches/*.sh; frame-fix
injection attributed to scripts/patches/app-asar.sh; the
version-bump checks now grep scripts/setup/detect-host.sh.
docs/BUILDING.md — automated version
detection paragraph now mentions scripts/setup/detect-host.sh as
the file that holds the URLs.
docs/learnings/cowork-vm-daemon.md — Patch 6 pointer now
says scripts/patches/cowork.sh; line-number references dropped in
favour of anchor-based search (line numbers drift between releases).
docs/learnings/plugin-install.md — Key Files section
points at scripts/patches/cowork.sh for patch_cowork_linux.
Historical changelog-style references (e.g. docs/cowork-linux-handover.md
describing what was "added to build.sh" during initial cowork work)
are intentionally left unchanged — they describe a point-in-time state
of the codebase.
Co-Authored-By: Claude <claude@anthropic.com>
Updates the inline prompt text that guides the triage investigation
agent so it looks for patches in the correct location. The previous
prompt told the agent "search build.sh for patch_ functions" — those
functions have moved into scripts/patches/*.sh organized by subsystem
(tray, cowork, claude-code, quick-window, titlebar, app-asar).
Without this, the triage agent would open build.sh, find only the
orchestrator's source statements, and fail to locate the actual
patch logic — producing lower-quality diagnoses.
Three prompt blocks updated: the "How This Project Patches" section,
the "All bugs are ours to fix" checklist, and the "Patch Approach"
output format. build.sh itself still appears as the orchestrator
reference for context.
Co-Authored-By: Claude <claude@anthropic.com>
The auto-version-bump workflow greps/seds against the Claude Desktop
download URLs and SHA-256 checksums. With the build.sh split those
declarations now live in scripts/setup/detect-host.sh inside
detect_architecture's case statement.
Without this fix, the next upstream release triggers the workflow
and it silently fails to update either the URLs or the checksums
(greps return empty, seds match nothing, git diff finds no changes,
no commit, no tag).
Updates all 17 references — grep targets, sed targets, git
diff/add paths, and step labels / echo messages for consistency.
The patterns themselves (x86_64) / aarch64) case matching,
claude_download_url=' extraction, in-range claude_exe_sha256
replacement) are unchanged and still match the new file's content.
Co-Authored-By: Claude <claude@anthropic.com>
Passes -x (--external-sources) to shellcheck so it follows the
'# shellcheck source=...' directives in build.sh and checks the
split modules in their sourced context. Without this, every sourced
module triggers SC1091 (can't follow source) plus SC2154/SC2034
noise from cross-file variable usage.
Also quotes $script_dir inside $(dirname $script_dir) in
scripts/packaging/rpm.sh — the heredoc-embedded command
substitution tripped SC2086 once shellcheck started analyzing the
subshell context.
Co-Authored-By: Claude <claude@anthropic.com>