Skip to content

feat: ship CompozyOS desktop app - #336

Merged
pedronauck merged 18 commits into
mainfrom
desktop-app
Aug 10, 2026
Merged

pedronauck merged 18 commits into
mainfrom
desktop-app

Conversation

@pedronauck

@pedronauck pedronauck commented Aug 10, 2026 •

Copy link
Copy Markdown
Member

Summary

This PR delivers the CompozyOS desktop app workstream end to end across the native shell, runtime contracts, agent-facing CLI, update system, release pipeline, public product language, documentation, and release-grade QA tracking.

The desktop app is a thin Tauri shell over the real CompozyOS daemon. It attaches to an existing daemon when possible, starts or provisions an app-owned runtime when needed, keeps native links and window behavior deterministic, and exposes the same lifecycle to agents through structured compozy app commands.

What changed

Native desktop shell

  • Added the Tauri desktop shell, canonical app-state schema, boot/product windows, single-instance handling, native link routing, external-link protection, geometry recovery, load deadlines, and product watchdogs.
  • Added attach-first runtime discovery with explicit process ancestry, executable provenance, identity probing, bounded daemon-record reads, supervised startup, readiness, quiesce, and honest recovery states.
  • Split the boot surface into a small HTML shell plus dedicated CSS and JavaScript, with safe diagnostics, state-gated retry, and app/runtime update actions.
  • Kept production Rust sources below the 500-line responsibility cap by separating durability and Tauri updater adapters from update policy.

Agent and runtime surfaces

  • Added structured compozy app status, open, retry, diagnose, quit, and update controls with public-safe errors and a bounded control-socket protocol.
  • Added [app] configuration lifecycle support, including shared Go/Rust duration grammar, validation corpus, canonical defaults, and config tool-surface registration.
  • Added desktop provenance detection and fail-closed update ownership decisions while preserving managed-install recommendations.
  • Hardened deep-link target parsing against fragments, encoded traversal, encoded slashes, and malformed paths.

Update and release integrity

  • Added app and runtime update policy, consent, signed-feed checks, atomic intent/payload persistence, signature revalidation, mutation locking, transactional swaps, recovery journals, and cross-platform durability.
  • Added fail-closed desktop release inventory, schema-head discovery, deterministic feed generation, strict SemVer/channel policy, signing/notarization helpers, and distribution-origin validation.
  • Added three-platform release wiring and custody documentation while keeping publication blocked until the release evidence gates are satisfied.

Product language, site, and docs

  • Completed the Compozy → CompozyOS public product-language hard cut while retaining compozy as the binary/command name.
  • Updated site identity fallbacks, marketplace copy, OpenGraph labels, footer copy, bridge metadata, fixtures, generated-contract consumers, and the official desktop documentation surfaces.

Deep review

One full, spec-aware deep-review round ran against a frozen diff using Codex gpt-5.6-terra with xhigh reasoning.

  • 88 canonical findings: 21 defects and 67 advisories.
  • All 88 findings were remediated in the single review round.
  • The remediation includes duration parity, update locking, timeout/error behavior, stale relaunch handling, durable signed payloads, process cleanup, cross-platform fsync, release validation, CLI target security, config registration, test consolidation, and source-structure cleanup.

Verification

  • make gate-full: PASS, current fingerprint 4345373d98dda62105dcdab96e049679fac86bf7.
  • Full gate log: .cache/gate/logs/full-1786369113.log (finished 2026-08-10T13:40:30Z).
  • Rust: 106 tests across 7 suites passed; cargo check --all-targets passed.
  • Focused Go validation: 3,223 tests across the initially affected packages plus 2,880 tests across the final touched packages passed with -race.
  • Site: 321 tests passed.
  • Web: 4,521 tests passed.
  • Bun lint/typecheck, Go lint, codegen checks, installer checks, web build, Go build, source-size checks, and boundaries passed through make verify.
  • Strict QA evidence audit: PASS with zero blockers and zero warnings.
  • QA teardown: teardown.json records clean: true with zero surviving processes.

QA verdict

The code and automated gates are green, but the release verdict remains BLOCKED — NO SHIP.

  • 13/13 APP scenarios are terminal: 2 pass, 11 blocked-verify, 0 untested/fail.
  • macOS runtime and agent-control paths were exercised in an isolated lab.
  • Signed app/runtime update rehearsal is still blocked by missing signed staging feeds.
  • Windows/WebView2 and Linux/WebKitGTK charters are blocked by unavailable runners.
  • E2E-021 still needs measured 10-minute profiles from three real webviews.
  • The production release job was intentionally not invoked without an authorized non-publishing rehearsal target.

The canonical report is docs/qa/reports/2026-08-10-desktop-app-release.md.

Compozy Impact Audit

  • Native tools: No native compozy__* tool IDs, toolsets, descriptors, schemas, digests, risk flags, or capability gates changed. The agent-facing addition is the structured CLI/control-socket compozy app surface.
  • Extensibility and hooks: No extension, hook, skill/capability, resource, registry, bridge SDK, or MCP sidecar contract changed. App configuration keys are registered in the canonical config tool surface and follow the normal config.toml lifecycle.
  • Workspace data isolation: Desktop app state, sockets, update intents, provenance, and journals are global to the selected COMPOZY_HOME. No workspace/session/agent datum or workspace_id propagation path changed across CLI, HTTP, UDS, core, store, web, SSE, cache, or events.
  • Official Compozy skill: The desktop behavior and agent-operable CLI surface are documented in the built-in Compozy skill; the review fixes do not introduce additional public commands or tool IDs.

Release follow-through

Before shipping a desktop release, run the blocked Windows, Linux, signed-update, native visual, and three-webview SSE charters recorded in the QA report. The implementation intentionally fails closed until that evidence exists.

Summary by CodeRabbit

  • New Features

    • Added a CompozyOS desktop app with startup status, runtime discovery, deep links, diagnostics, health monitoring, and window management.
    • Added app commands for opening, focusing, checking status, retrying operations, and managing updates.
    • Added secure runtime provisioning, verification, recovery, and update workflows.
    • Added detailed drain status, including active work and safe-to-stop state.
    • Added desktop release tooling and configurable update checks.
  • Documentation & Style

    • Updated product branding and user-facing messaging from Compozy to CompozyOS across the CLI, web app, site, integrations, and release materials.

@greptile-apps

greptile-apps Bot commented Aug 10, 2026 •

Copy link
Copy Markdown

Too many files changed for review (584 files, 100 file limit).

@vercel

vercel Bot commented Aug 10, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
compozy-site Ready Ready Preview Aug 10, 2026 5:49pm

Request Review

@coderabbitai

coderabbitai Bot commented Aug 10, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d73081ec-5ae3-4023-a669-e79656f0cd70

📥 Commits

Reviewing files that changed from the base of the PR and between 8e121c9 and 0b749b7.

📒 Files selected for processing (6)
  • Makefile
  • scripts/build-air.sh
  • scripts/desktop-dev.sh
  • scripts/dev-common.sh
  • scripts/dev-http-endpoint/main.go
  • scripts/dev.sh

Disabled knowledge base sources:

  • Linear integration is disabled

You can enable these sources in your CodeRabbit configuration.


Walkthrough

The PR adds a Tauri desktop shell with runtime discovery, provisioning, updates, health monitoring, app control, and release tooling. It also adds desktop configuration and state contracts, drain-status reporting, product-language checks, and broad CompozyOS terminology updates.

Changes

Desktop shell and runtime

Layer / File(s) Summary
Desktop runtime lifecycle
desktop/src-tauri/..., desktop/schema/...
Adds runtime discovery, verification, provisioning, supervision, quiescence, journaled updates, health monitoring, state persistence, and Tauri window management.
Boot UI and app control
desktop/src-tauri/pages/..., desktop/src-tauri/src/control.rs, desktop/src-tauri/src/controller.rs
Adds the boot page, sanitized error handling, Unix-socket control protocol, update commands, diagnostics, retries, and navigation handling.

Release and CLI integration

Layer / File(s) Summary
Desktop release workflow
cmd/compozy-desktop-release/..., internal/desktoprelease/..., scripts/*desktop*, scripts/*manifest*
Adds feed and channel configuration generation, artifact inventory checks, signing validation, version policy checks, publication verification, and release scripts.
App CLI and daemon status
internal/cli/app*, internal/daemon/drain.go, internal/api/.../drain.go
Adds app status/open/update commands and exposes drain settlement, admission, active-work, and safe-to-stop fields across API and CLI output.

Repository terminology and configuration

Layer / File(s) Summary
Configuration and update detection
internal/config/..., internal/update/...
Adds desktop update-check settings, runtime mutation-lock configuration, desktop installation provenance detection, and desktop-app update recommendations.
CompozyOS product language
.env.example, internal/cli/..., packages/site/..., web/..., sdk/go/..., scripts/...
Updates product names, help text, generated metadata, site content, fixtures, and tests from Compozy to CompozyOS. Adds the product-language validation task.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

  • compozy/compozy#112: The desktop shell and daemon/update-lock integration build on the daemon-backed CLI and API architecture introduced by this PR.
  • compozy/compozy#116: Both PRs modify daemon startup flow and startup locking in internal/cli/daemon.go.
  • compozy/compozy#265: Both PRs cover CompozyOS beta release preparation and product-transition changes.

Suggested labels: release-pending

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 24.90% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: delivering the CompozyOS desktop app.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch desktop-app

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@github-actions

github-actions Bot commented Aug 10, 2026 •

Copy link
Copy Markdown
Contributor

React Doctor found no new issues. 🎉

Reviewed by React Doctor for commit 0b749b7.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

🟠 Major comments (22)
internal/desktoprelease/channel_config.go-83-90 (1)

83-90: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Validate the complete Minisign public-key format.

validatePublicKey accepts any base64 value whose decoded bytes contain "minisign public key". A malformed key can pass validation and be written into the updater configuration. The desktop updater then cannot authenticate release signatures.

Require the documented public-key structure and a valid decoded key packet before writing the configuration. Replace the placeholder payload in internal/desktoprelease/release_test.go:239-269 with a valid test key, and add a rejection case for malformed key material.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/desktoprelease/channel_config.go` around lines 83 - 90, Update
validatePublicKey to require the complete documented Minisign public-key
structure and successfully parse/validate its decoded key packet, rather than
only checking for the marker text. Reject malformed key material before
configuration is written. Replace the placeholder payload in the existing
release tests with a valid Minisign public key and add a malformed-key rejection
case.
scripts/publish-desktop-release.sh-69-75 (1)

69-75: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Bind runtime payloads to the signed runtime manifest.

BuildFeeds validates runtime hashes when it creates runtime.json. This script receives runtime_dir separately and uploads its files without comparing them to feed_dir/runtime.json. It then verifies only the remote manifest signature.

A stale or changed runtime payload can publish successfully and later fail client SHA-256 validation. Validate every runtime file against the signed manifest before upload. Download each declared runtime payload after upload and verify its size and SHA-256 before reporting success.

Based on supplied context, internal/desktoprelease/manifest.go:27-69 binds runtime hashes only during feed construction.

Also applies to: 95-115

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/publish-desktop-release.sh` around lines 69 - 75, Update the runtime
upload flow around the runtime_path loop to load the signed
feed_dir/runtime.json and validate every runtime_dir payload against its
declared filename, size, and SHA-256 before uploading. After each upload,
download the declared object and recheck its size and SHA-256, failing on
missing, extra, or mismatched payloads before reporting success; retain the
existing manifest signature verification.
scripts/notarize-staple-dmg.sh-19-23 (1)

19-23: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Validate the embedded app bundle before notarization.

app_path is only checked before notarytool submit "${dmg_path}"; scripts/normalize-desktop-artifacts.sh also keeps the macOS app tarball and DMG in separate bundle directories. Mount the DMG read-only, locate the embedded .app, and compare its CFBundleShortVersionString to ${release_version} before submission so an old DMG with a different app cannot pass.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/notarize-staple-dmg.sh` around lines 19 - 23, Before the notarytool
submit step, mount dmg_path read-only, locate the embedded .app inside the
mounted DMG, and validate its CFBundleShortVersionString against release_version
using the existing PlistBuddy check. Ensure the mounted volume is cleaned up on
success and failure, and keep the existing app_path validation separate.
scripts/generate-desktop-update-key.sh-15-17 (1)

15-17: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Restrict generated private-key permissions.

The script calls bunx @tauri-apps/cli@2.11.4 signer generate --write-keys "${output}" without setting an access-restricted umask or enforcing 0600 on the generated TAURI_SIGNING_PRIVATE_KEY file. With a common umask 022, the private key can be created readable by other local users. Set umask 077 before generation, then set the output file mode to 0600.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/generate-desktop-update-key.sh` around lines 15 - 17, Update the
key-generation flow in scripts/generate-desktop-update-key.sh to set umask 077
before invoking the signer generate command, then enforce mode 0600 on the
generated output file after generation.
scripts/normalize-desktop-artifacts.sh-15-17 (1)

15-17: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Make copy_one fail when find fails.

find runs in Bash process substitution, so set -u pipefail does not expose its non-zero exit status to the while loop. If find reports one match and then exits non-zero for a permission/cleanup error, this function can copy only that partial result. Capture and check find’s status before evaluating the match count; use -print0 if the fix must preserve paths with special characters.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/normalize-desktop-artifacts.sh` around lines 15 - 17, Update
copy_one’s find/match collection so a non-zero find exit status is captured and
causes the function to fail before evaluating the match count or copying
results. Preserve all successfully discovered matches, and use null-delimited
output with -print0 and matching read handling if needed to support paths
containing special characters.
scripts/verify-desktop-signature.sh-19-26 (1)

19-26: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Make base64 decoding portable.

On macOS, base64 --decode at Line 19 and Line 26 fails because BSD base64 uses -D. The signature verifier cannot validate macOS release artifacts.

Select the decoder by platform, or use a portable decoding command.

Proposed fix
+decode_base64() {
+  if [[ "$(uname -s)" == "Darwin" ]]; then
+    base64 -D
+  else
+    base64 --decode
+  fi
+}
+
-printf '%s' "${public_key}" | base64 --decode >"${verify_dir}/public.key"
+printf '%s' "${public_key}" | decode_base64 >"${verify_dir}/public.key"
...
-    base64 --decode <"${signature}" >"${verify_dir}/signature.minisig"
+    decode_base64 <"${signature}" >"${verify_dir}/signature.minisig"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/verify-desktop-signature.sh` around lines 19 - 26, Make the base64
decoding in the verification script portable across GNU and BSD systems by
selecting the appropriate decode option or command for the current platform.
Update both the public-key decoding before the signature case and the
encoded-signature decoding in the default branch, while leaving the existing
.minisig copy path unchanged.
packages/site/lib/og/templates/blog.tsx-63-63 (1)

63-63: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Use the canonical eyebrow implementation for this label.

Line 63 renders COMPOZYOS BLOG in a styled <span> with inline textTransform: "uppercase". Use <Eyebrow> or the eyebrow utility class for the label.

As per coding guidelines, every uppercase label in packages/site/**/*.{tsx,mdx} must use <Eyebrow> or the eyebrow utility class.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/site/lib/og/templates/blog.tsx` at line 63, Replace the styled span
rendering “COMPOZYOS BLOG” with the canonical Eyebrow component or eyebrow
utility class, removing the inline uppercase styling while preserving the
label’s existing accent color.

Source: Coding guidelines

desktop/src-tauri/src/app_state.rs-45-67 (1)

45-67: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Serialize persist so concurrent publishers cannot write a stale record.

publish and update_status release the state mutex before they call persist. Two threads can therefore interleave: thread A captures state S1, thread B captures S2 and writes S2, then thread A writes S1. The persisted app_record and the rendered boot payload then hold an older state than the in-memory state.

The path is reachable. desktop/src-tauri/src/control.rs (lines 95-108) spawns a thread per control connection, and each apply_update call publishes (desktop/src-tauri/src/controller.rs, lines 128-131 and 169-171). desktop/src-tauri/src/shell.rs also spawns a retry thread that runs the coordinator. app_record is the cross-language contract that the Go reader consumes through desktop/schema/schema.go, so a stale write is observable outside the process.

Hold a dedicated ordering lock across the snapshot and the write.

🔒️ Proposed fix using a persistence ordering lock
 #[derive(Clone)]
 pub struct AppStatePublisher {
     app: AppHandle<Wry>,
     home: CompozyHome,
     started_at: DateTime<Utc>,
     diagnostic: Option<ShellError>,
     state: Arc<Mutex<PublishedState>>,
+    persist_order: Arc<Mutex<()>>,
 }
@@
             state: Arc::new(Mutex::new(PublishedState {
                 shell: ShellState::Resolving,
                 update: AppUpdateStatus::default(),
             })),
+            persist_order: Arc::new(Mutex::new(())),
         }
     }
 
     pub fn publish(&self, state: ShellState) {
+        let _order = self
+            .persist_order
+            .lock()
+            .unwrap_or_else(|poisoned| poisoned.into_inner());
         let update = {
             let mut published = self
                 .state
                 .lock()
                 .unwrap_or_else(|poisoned| poisoned.into_inner());
             published.shell = state.clone();
             published.update.clone()
         };
         self.persist(state, update);
     }
 
     pub fn update_status(&self, mutate: impl FnOnce(&mut AppUpdateStatus)) {
+        let _order = self
+            .persist_order
+            .lock()
+            .unwrap_or_else(|poisoned| poisoned.into_inner());
         let (shell, update) = {

Also applies to: 85-111

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@desktop/src-tauri/src/app_state.rs` around lines 45 - 67, Serialize
persistence in both publish and update_status by adding or reusing a dedicated
ordering lock held across the state snapshot and persist call. Ensure the lock
is acquired before locking state and remains held through self.persist, so
concurrent publishers cannot write an older snapshot after a newer one; do not
rely on the existing state mutex alone.
desktop/src-tauri/src/control.rs-79-83 (1)

79-83: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

ControlServer::start still only uses the non-atomic socket_is_live check, remove_socket, and bind; it starts accepting before set_socket_permissions.

Enforce single-instance exclusively with an advisory lock around socket ownership.

tauri-plugin-single-instance handles a second app launch forwarding arguments, but app.sock can still be stolen or created by a concurrent process that starts after the probe and before the binder succeeds. Use a sibling lock file with exclusive file locking from start-up until the control server binds and persists ownership through shutdown.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@desktop/src-tauri/src/control.rs` around lines 79 - 83, Update
ControlServer::start to replace the socket_is_live/remove_socket race-prone
ownership check with an exclusive advisory lock on a sibling lock file, acquired
before probing or binding and retained for the control server’s lifetime through
shutdown. Ensure the lock is held until bind and set_socket_permissions
complete, and release it only when the server stops; return the existing
AlreadyListening error when the lock cannot be acquired.
desktop/src-tauri/src/control.rs-110-117 (1)

110-117: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Do not terminate the accept loop on transient accept errors.

Line 113 breaks the loop for every error kind other than WouldBlock. accept returns transient errors such as Interrupted (EINTR), ConnectionAborted (ECONNABORTED), and file-descriptor exhaustion (EMFILE/ENFILE). After a break, the loop removes the socket file and app control stays unavailable for the rest of the process lifetime. ControlServer has no restart path, and the only signal is one log line.

Continue on recoverable error kinds with a short backoff.

🛡️ Proposed fix
                     Err(error) if error.kind() == ErrorKind::WouldBlock => {
                         thread::sleep(Duration::from_millis(20));
                     }
+                    Err(error)
+                        if matches!(
+                            error.kind(),
+                            ErrorKind::Interrupted | ErrorKind::ConnectionAborted
+                        ) =>
+                    {
+                        continue;
+                    }
+                    Err(error) if error.raw_os_error() == Some(libc::EMFILE) => {
+                        crate::logging::error(format!(
+                            "accept app control connection deferred: {error}"
+                        ));
+                        thread::sleep(Duration::from_millis(100));
+                    }
                     Err(error) => {
                         crate::logging::error(format!("accept app control connection: {error}"));
                         break;
                     }

If you prefer no new dependency on libc, match on ErrorKind::Other with a bounded retry counter instead of the raw_os_error check.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@desktop/src-tauri/src/control.rs` around lines 110 - 117, Update the accept
loop’s generic Err(error) branch in ControlServer to continue retrying
recoverable transient accept failures, including Interrupted, ConnectionAborted,
and descriptor-exhaustion errors, with a short backoff instead of breaking.
Preserve logging, and retain termination for non-recoverable errors so the
server does not spin indefinitely.
desktop/src-tauri/src/controller.rs-471-478 (1)

471-478: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Offload shell_control from the main thread during update applies.

shell_control is a synchronous Tauri 2 command, and apply_update runs blocking updater work before returning. Move long-running update handling to an async command and tauri::async_runtime::spawn_blocking, or else the boot window cannot process the rendered "Updating ComozyOS" state until the update completes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@desktop/src-tauri/src/controller.rs` around lines 471 - 478, Update
shell_control to use an asynchronous Tauri command and offload its synchronous
controller.handle call to tauri::async_runtime::spawn_blocking, preserving the
trimmed method and params and returning the existing Result<Value, ControlError>
outcome after the blocking work completes. Ensure apply_update no longer blocks
the boot window’s main thread while the update is running.
desktop/src-tauri/src/control.rs-76-91 (1)

76-91: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Narrow the socket permissions before the listener accepts connections.

bind at line 86 creates the socket file with the process umask, normally 0755. listen at line 87 then starts accepting connections. set_socket_permissions narrows the mode to 0600 only at line 91. Between lines 87 and 91 any local user can connect and issue update.apply, navigate, or diagnose. diagnose returns the app and runtime log paths (desktop/src-tauri/src/controller.rs, lines 310-317), and update.apply triggers a runtime mutation.

Set the mode before listen, and create the parent directory with owner-only permissions.

🔒️ Proposed fix
         if let Some(parent) = path.parent() {
             fs::create_dir_all(parent).map_err(ControlServerError::Io)?;
+            set_directory_permissions(parent)?;
         }
@@
         listener.bind(&address).map_err(ControlServerError::Io)?;
-        listener.listen(32).map_err(ControlServerError::Io)?;
+        set_socket_permissions(path)?;
+        listener.listen(32).map_err(ControlServerError::Io)?;
         listener
             .set_nonblocking(true)
             .map_err(ControlServerError::Io)?;
-        set_socket_permissions(path)?;

Add the directory helper next to set_socket_permissions:

#[cfg(unix)]
fn set_directory_permissions(path: &Path) -> Result<(), ControlServerError> {
    use std::os::unix::fs::PermissionsExt;

    fs::set_permissions(path, fs::Permissions::from_mode(0o700)).map_err(ControlServerError::Io)
}

#[cfg(not(unix))]
fn set_directory_permissions(_path: &Path) -> Result<(), ControlServerError> {
    Ok(())
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@desktop/src-tauri/src/control.rs` around lines 76 - 91, Update the socket
setup flow around `set_socket_permissions` so the socket mode is narrowed to
owner-only before `listener.listen(32)` runs, then retain the existing
nonblocking setup. Add and invoke a `set_directory_permissions` helper after
`fs::create_dir_all` to enforce mode 0700 on the parent directory, with the same
Unix and non-Unix conditional behavior described in the comment.
desktop/src-tauri/src/errors.rs-13-18 (1)

13-18: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Extend NAMED_SECRET to the secret classes the repository guidelines name.

The current alternation matches only claim_token, authorization, api_key, and access_token. Values such as refresh_token=…, client_secret=…, code_verifier=…, code=…, and mcp_token=… pass through unredacted. sanitize_public_text also feeds logging::error, so an unmatched secret reaches the desktop log file and the public error surface.

🔒️ Proposed fix
 static NAMED_SECRET: LazyLock<Regex> = LazyLock::new(|| {
     Regex::new(
-        r#"(?i)(claim_token|authorization|api[_-]?key|access[_-]?token)\s*[:=]\s*[^\s,;\"']+"#,
+        r#"(?i)((?:proxy[_-]?)?authorization|claim[_-]?token|mcp[_-]?token|api[_-]?key|(?:access|refresh|id|session|bearer)[_-]?token|client[_-]?secret|code[_-]?(?:verifier|challenge)|password|passphrase|secret)\s*[:=]\s*[^\s,;\"']+"#,
     )
     .expect("named secret regex is valid")
 });

Based on learnings from the coding guidelines: "Never expose raw claim tokens, MCP tokens, OAuth codes, PKCE verifiers, or secret bindings in logs, APIs, errors, messages, SSE, UI, or memory".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@desktop/src-tauri/src/errors.rs` around lines 13 - 18, Extend the
NAMED_SECRET regex alternation to match refresh_token, client_secret,
code_verifier, code, and mcp_token bindings in addition to the existing secret
names, preserving its case-insensitive matching and current key-value boundary
behavior so sanitize_public_text redacts all repository-defined secret classes.

Source: Coding guidelines

desktop/src-tauri/src/health_monitor.rs-99-110 (1)

99-110: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Keep the health monitor running on version skew.

health_monitor::spawn is only called from open_product, which runs after a compatible handshake. The skew branches show the skew UI and call return, so the monitor thread exits permanently. If the operator replaces the runtime with a compatible version, this path no longer re-evaluates compatibility. Re-arm a new monitor on skew, or keep the loop running and perform another compatibility check next tick.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@desktop/src-tauri/src/health_monitor.rs` around lines 99 - 110, Update the
skew handling in health_monitor::spawn so Compatibility::SkewOlder and
Compatibility::SkewNewer display the skew UI without terminating the monitor.
Keep the monitoring loop alive and ensure compatibility is checked again on the
next tick, or re-arm an equivalent monitor using the existing spawn flow.
desktop/src-tauri/src/home.rs-68-103 (1)

68-103: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Keep COMPOZY_HOME resolution independent from env::current_dir().

ResolveHomeDir() and ResolveHomePathsFrom() resolve COMPOZY_HOME directly, even for relative inputs, so desktop/src-tauri/src/home.rs should not join relative overrides to the process current directory. The existing "relative/home" case should not resolve both sides the same way if the Go side only returns the relative value.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@desktop/src-tauri/src/home.rs` around lines 68 - 103, Update resolve_with and
absolute_expanded so relative COMPOZY_HOME overrides remain relative instead of
being joined with current_dir; remove the current_dir-based resolution while
preserving tilde expansion and absolute paths. Keep the Rust result aligned with
ResolveHomeDir()/ResolveHomePathsFrom(), including the existing relative/home
behavior.
internal/cli/daemon.go-159-181 (1)

159-181: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Release the update lock before starting the foreground runner.

runDaemonForegroundMode acquires update.lock and defers release until runner.Run(ctx) returns. In foreground mode this holds the global cross-process runtime mutation lock for the whole daemon lifetime, so another command using the same lock takes ErrRuntimeMutationInProgress until that foreground session shuts down. For the detached path, the parent releases the lock after waitForDaemonStart; hold the lock for the same duration, or release it earlier in both paths.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cli/daemon.go` around lines 159 - 181, The update lock acquired in
runDaemonForegroundMode must be released before starting the foreground runner,
rather than being deferred for the daemon’s entire lifetime. Release updateLock
after the startup-critical work completes and remove or adjust the deferred
release so runner.Run does not retain the global mutation lock; preserve error
propagation from lock release.
desktop/src-tauri/src/runtime/provision.rs-76-150 (1)

76-150: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Ensure only one provisioning/update path can hold provision/stage_update.

stage_update is called after updating shell.rs’s runtime updater, so the update path already holds UpdateLock. provision is called from ShellCoordinator::provision_runtime, but this happens after recover_runtime releases UpdateLock, so another invocation or process can mutate home.update_lock, the shared staging paths, and home.app_binary at the same time. Add a caller-held UpdateLock around resolve/provision, or a single-instance/provision lock before reaching provision.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@desktop/src-tauri/src/runtime/provision.rs` around lines 76 - 150, The
provisioning flow lacks exclusive coordination with stage_update, allowing
concurrent mutations of the update lock, staging paths, and installed binary.
Add a caller-held UpdateLock or equivalent single-instance provisioning lock
before ShellCoordinator::provision_runtime resolves and invokes provision, while
preserving stage_update’s existing lock ownership and ensuring the lock covers
the full resolve/provision operation.
internal/cli/app_types.go-102-124 (1)

102-124: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Validate each app JSON shape with its own schema.

validateAppStateJSON uses one root schema for app.json and AppStatusReport. The root required list does not constrain either shape: it ignores the record-level required fields (started_at, state), and it ignores the status-level required fields (installed, running, update). Split validation into two definitions: one for the persisted record and one for the status report.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cli/app_types.go` around lines 102 - 124, Split the shared
validation used by validateAppStateJSON into separate schemas for the persisted
app.json record and AppStatusReport. Ensure the record schema requires
started_at and state, while the status schema requires installed, running, and
update, and route each shape through its corresponding definition.
internal/cli/app.go-205-218 (1)

205-218: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

appRecordRunning skips the schema-version gate that resolveAppStatus enforces.

resolveAppStatus in internal/cli/app_types.go rejects app.json when schema_version != appStateSchemaVersion and returns appStateSchemaUnknownCode. appRecordRunning decodes only pid and started_at, so app open treats a record written by a newer app version as authoritative and sends a navigate control call. The two commands then disagree about the same file.

Reuse one liveness reader that validates schema_version first, and return a typed error for an unknown version.

♻️ Proposed direction
-func appRecordRunning(homePaths compozyconfig.HomePaths, deps commandDeps) bool {
-	raw, err := os.ReadFile(filepath.Join(homePaths.HomeDir, "app.json"))
-	if err != nil {
-		return false
-	}
-	var identity struct {
-		PID       int       `json:"pid"`
-		StartedAt time.Time `json:"started_at"`
-	}
-	if err := json.Unmarshal(raw, &identity); err != nil || identity.PID <= 0 {
-		return false
-	}
-	return deps.processAlive(identity.PID) && deps.processMatchesStartTime(identity.PID, identity.StartedAt)
-}
+func appRecordRunning(
+	ctx context.Context,
+	homePaths compozyconfig.HomePaths,
+	deps commandDeps,
+) (bool, error) {
+	report, err := resolveAppStatus(ctx, deps, homePaths)
+	if err != nil {
+		return false, err
+	}
+	return report.Running, nil
+}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cli/app.go` around lines 205 - 218, Update appRecordRunning and the
shared app-state reading flow to validate schema_version against
appStateSchemaVersion before using PID or StartedAt, matching resolveAppStatus
behavior. Reuse a single liveness reader for this validation and return a typed
error representing an unknown schema version; ensure app open does not treat
unsupported app.json records as authoritative or issue the navigate control
call.
internal/daemon/drain.go-94-121 (1)

94-121: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Read the drain state before counting active work.

The function counts active executions and claims first (lines 94-111), then reads d.DrainState() at line 113. If admission closes between the counts and the state read, the response combines old counts with the new state and can report SafeToStop: true while work admitted after the count is still in flight. desktop/src-tauri/src/runtime/quiesce.rs treats that readout as authoritative and stops the process.

Read the state first. Then any count is taken at a moment when admission was already closed, and SafeToStop: true is conservative.

🔒 Proposed fix
+	state := d.DrainState()
 	activeExecutions := 0
 	executionsKnown := sessions == nil
 	if promptState, ok := sessions.(sessionPromptStateReader); ok {
 		executionsKnown = true
 		for _, info := range sessions.List() {
 			if info != nil && promptState.IsPrompting(info.ID) {
 				activeExecutions++
 			}
 		}
 	}
 	activeClaims := 0
 	if claimCounter != nil {
 		count, err := claimCounter.CountActiveTaskRunClaims(ctx)
 		if err != nil {
 			return contract.DrainStatusResponse{}, fmt.Errorf("daemon: count active task claims: %w", err)
 		}
 		activeClaims = count
 	}
-
-	state := d.DrainState()
 	return composeDrainStatus(
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/daemon/drain.go` around lines 94 - 121, Update the drain-status
function to call d.DrainState() before counting active executions and claims,
then pass that captured state to composeDrainStatus. Preserve the existing count
error handling and response construction while ensuring all work counts are
collected after the admission state snapshot.
desktop/src-tauri/src/runtime/supervisor.rs-132-171 (1)

132-171: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Bound the lock-contention retries; attempt does not increment on these paths.

start_owned increments attempt only at line 171, after it acquires the lock. Three continue statements run before that point:

  • Line 141, after reclaim_if_stale returns Ok(true).
  • Line 157, after wait_for_competing_start returns None and the lock file is gone.

Each of these repeats the while attempt < ATTEMPT_BACKOFF.len() body with the same attempt value. If a competing process repeatedly creates and removes app-start.lock, or repeatedly leaves a stale lock owned by a dead pid, the loop never terminates. The Ok(true) path and the "lock disappeared" path also apply no sleep, so the loop spins without a delay and consumes CPU.

Add a separate bounded counter for contention rounds so every path makes progress toward a terminal result.

🔒 Proposed fix to bound contention rounds
     pub fn start_owned(
         &self,
         binary: &Path,
         home: &CompozyHome,
     ) -> Result<StartResult, ShellError> {
         let lock_path = home.root.join("app-start.lock");
         let mut attempt = 0;
+        let mut contention_rounds = 0usize;
+        const MAX_CONTENTION_ROUNDS: usize = 16;
         while attempt < ATTEMPT_BACKOFF.len() {
             if let Some(identity) = self.readiness.ready(None) {
                 return Ok(StartResult::Attached(identity));
             }
             let lock = match StartLock::acquire(&lock_path) {
                 Ok(lock) => lock,
                 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
+                    contention_rounds += 1;
+                    if contention_rounds > MAX_CONTENTION_ROUNDS {
+                        return Err(start_error(home));
+                    }
                     match StartLock::reclaim_if_stale(&lock_path, self.readiness) {
-                        Ok(true) => continue,
+                        Ok(true) => {
+                            self.delay.sleep(self.poll_interval);
+                            continue;
+                        }
                         Ok(false) => {}
                         Err(error) => {
                             crate::logging::error(format!(
                                 "reclaim runtime start lock {}: {error}",
                                 lock_path.display()
                             ));
                             return Err(start_error(home));
                         }
                     }
                     if let Some(identity) = self.wait_for_competing_start(&lock_path) {
                         return Ok(StartResult::Attached(identity));
                     }
                     if lock_path.exists() {
                         return Err(start_error(home));
                     }
+                    self.delay.sleep(self.poll_interval);
                     continue;
                 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@desktop/src-tauri/src/runtime/supervisor.rs` around lines 132 - 171, Bound
lock-contention retries in start_owned with a separate counter that advances
before both continue paths handling reclaim_if_stale Ok(true) and a disappeared
lock after wait_for_competing_start. Apply the existing backoff delay for each
contention round and return start_error(home) once the contention limit is
reached, while preserving the current attached and successful lock-acquisition
behavior.
desktop/src-tauri/tests/fake_daemon.rs-104-114 (1)

104-114: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Handle ECHILD in terminate_fixture.

SystemSpawner::spawn_detached uses setsid, and Setsid::setsid removes the caller from its process group, so waitpid(pid, 0) can fail with ECHILD after the signal is sent. Treat that failure as successful cleanup after libc::kill returns 0, or otherwise reap this fixture after termination.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@desktop/src-tauri/tests/fake_daemon.rs` around lines 104 - 114, Update
terminate_fixture so a successful libc::kill is treated as successful cleanup
even when waitpid returns ECHILD due to the detached process setup. Preserve
failure handling for unsuccessful termination signals, and otherwise continue
reaping the fixture when waitpid succeeds.
🟡 Minor comments (13)
desktop/src-tauri/pages/boot.js-96-118 (1)

96-118: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Guard runControl against a concurrent render from the host.

render is called while a control request is in flight. apply_update in desktop/src-tauri/src/controller.rs publishes ShellState::Updating (lines 128-131) before it calls updater.apply, and AppStatePublisher::persist renders the boot window on every publish (desktop/src-tauri/src/app_state.rs, lines 106-110).

Two effects follow:

  • configureButton sets button.disabled = false, removes aria-busy, and rewrites textContent and dataset.label. The pending state is lost, and the user can click again. The second update.apply is rejected by the runtime mutation lock, so the user sees an error instead of progress.
  • After the in-flight request settles, the finally block writes back label, which was captured at line 97 before the re-render. The button then shows a stale label that can disagree with dataset.target.

Track a pending flag and skip the label/disabled restore when render already reconfigured the button.

🐛 Proposed fix using a pending token
+  let controlGeneration = 0;
+
   const configureButton = (button, options) => {
     button.hidden = !options.visible;
     button.disabled = false;
     button.removeAttribute("aria-busy");
     button.classList.toggle("boot__control--primary", options.primary);
     button.textContent = options.label;
     button.dataset.label = options.label;
   };
@@
   const runControl = async (button, method, params, pendingLabel) => {
     const label = button.dataset.label;
+    const generation = ++controlGeneration;
     button.style.inlineSize = `${button.getBoundingClientRect().width}px`;
     button.disabled = true;
     button.setAttribute("aria-busy", "true");
     button.textContent = pendingLabel;
     setResult("");
     try {
       const response = await invoke(method, params);
       if (method === "diagnose") {
         showDiagnostics(response);
         return;
       }
       setResult(method === "retry" ? "Retry accepted." : "Update started.");
     } catch (error) {
       setResult(controlError(error), true);
     } finally {
-      button.disabled = false;
-      button.removeAttribute("aria-busy");
-      button.textContent = label;
-      button.style.removeProperty("inline-size");
+      if (generation === controlGeneration) {
+        button.disabled = false;
+        button.removeAttribute("aria-busy");
+        button.textContent = label;
+      }
+      button.style.removeProperty("inline-size");
     }
   };

Then bump the generation in render so an in-flight restore is discarded:

   const render = payload => {
     const input = payload !== null && typeof payload === "object" ? payload : {};
+    controlGeneration += 1;
     const state = Object.hasOwn(states, input.state) ? input.state : "error";

Also applies to: 120-124

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@desktop/src-tauri/pages/boot.js` around lines 96 - 118, Update runControl to
track a pending request token or generation and only restore the button’s label,
disabled state, aria-busy attribute, and inline size if render has not
reconfigured that control while the request was in flight. Increment the
corresponding generation in render/configureButton whenever the host re-renders
controls, so an in-flight finally block cannot overwrite the current
dataset.label or pending UI state.
internal/daemon/drain_status_test.go-62-70 (1)

62-70: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert the count fields so a field swap fails the test.

The test asserts SafeToStop, AuthoritativeClaimsSettled, and SessionExecutionSettled. It never asserts ActiveSessionExecutions or ActiveTaskClaims. If composeDrainStatus swapped those two response fields, every case would still pass, because the settled flags are computed from the input arguments and not from the response fields. DrainStatusResponse is the shared schema for HTTP, UDS, and CLI (internal/api/contract/drain.go, lines 11-20), so a swap is an observable contract break.

Add the two pass-through assertions. Also include the expected value in the failure messages at lines 66 and 69.

As per path instructions: "Verify tests can fail when business logic changes".

💚 Proposed additional assertions
 			if status.SafeToStop != tc.wantSafe {
 				t.Fatalf("composeDrainStatus() = %#v, want safe_to_stop=%t", status, tc.wantSafe)
 			}
+			if status.ActiveSessionExecutions != tc.activeExecutions {
+				t.Fatalf(
+					"active_session_executions = %d, want %d",
+					status.ActiveSessionExecutions,
+					tc.activeExecutions,
+				)
+			}
+			if status.ActiveTaskClaims != tc.activeClaims {
+				t.Fatalf("active_task_claims = %d, want %d", status.ActiveTaskClaims, tc.activeClaims)
+			}
+			if status.AdmissionClosed != (tc.state == DrainStateDraining) {
+				t.Fatalf("admission_closed = %#v", status)
+			}
 			if status.AuthoritativeClaimsSettled != (tc.claimsKnown && tc.activeClaims == 0) {
-				t.Fatalf("authoritative claims projection = %#v", status)
+				t.Fatalf(
+					"authoritative_claims_settled = %t, want %t",
+					status.AuthoritativeClaimsSettled,
+					tc.claimsKnown && tc.activeClaims == 0,
+				)
 			}
 			if status.SessionExecutionSettled != (tc.executionsKnown && tc.activeExecutions == 0) {
-				t.Fatalf("session execution projection = %#v", status)
+				t.Fatalf(
+					"session_execution_settled = %t, want %t",
+					status.SessionExecutionSettled,
+					tc.executionsKnown && tc.activeExecutions == 0,
+				)
 			}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/daemon/drain_status_test.go` around lines 62 - 70, Extend the
composeDrainStatus test to assert ActiveSessionExecutions equals
tc.activeExecutions and ActiveTaskClaims equals tc.activeClaims, so
response-field swaps fail. Update the existing AuthoritativeClaimsSettled and
SessionExecutionSettled failure messages to include their expected values, while
preserving the current settled-flag calculations.

Source: Path instructions

internal/api/core/handlers_test.go-4465-4476 (1)

4465-4476: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The stub cannot express "draining but not settled", so the settlement flags are untested.

DrainStatus returns AuthoritativeClaimsSettled: true and SessionExecutionSettled: true unconditionally, and derives SafeToStop from draining. Safety to stop depends on settlement, not on admission closure. Two consequences follow at the assertion sites:

  • Lines 4381-4384 assert all four flags are true while draining, but the two settlement flags are constants in the stub, so the assertion cannot fail if the handler stops projecting them.
  • Lines 4424-4426 never assert the settlement flags after undrain, so an inverted projection stays undetected.

Make the stub flags configurable and add the drain-but-unsettled case, where SafeToStop must be false.

💚 Proposed change
 func (c *testDrainController) DrainStatus(context.Context) (contract.DrainStatusResponse, error) {
 	state := c.DrainState()
 	draining := state == contract.DrainStateDraining
+	settled := c.settled.Load()
 	return contract.DrainStatusResponse{
 		State:                      state,
 		AdmissionClosed:            draining,
-		AuthoritativeClaimsSettled: true,
-		SessionExecutionSettled:    true,
-		SafeToStop:                 draining,
+		AuthoritativeClaimsSettled: settled,
+		SessionExecutionSettled:    settled,
+		SafeToStop:                 draining && settled,
 	}, nil
 }

Add the settled atomic.Bool field to testDrainController, set it to true in the existing case, and add a subtest that drains with settled false and asserts SafeToStop is false.

As per path instructions: "Verify tests can fail when business logic changes" and "Ensure tests verify behavior outcomes, not just function calls".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/api/core/handlers_test.go` around lines 4465 - 4476, Update
testDrainController and its DrainStatus method to use a configurable settled
atomic.Bool for both settlement flags, and derive SafeToStop from both draining
and settled. Set settled true in the existing settled case, add a
draining-but-unsettled subtest with settled false, and assert SafeToStop is
false; also assert the settlement flags after undrain.

Source: Path instructions

desktop/src-tauri/src/logging.rs-18-20 (1)

18-20: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

The 512-byte cap also truncates log records.

sanitize_public_text truncates to MAX_SAFE_MESSAGE_BYTES (512). That bound exists for the public UI surface in desktop/src-tauri/src/errors.rs. Applying it to the log file removes diagnostic detail exactly where full detail helps, and redaction already ran before truncation. Split the two concerns so logs keep the full redacted text.

🔧 Proposed change

Expose a redaction-only helper in desktop/src-tauri/src/errors.rs:

/// Redacts secrets and control characters without applying the public-surface length bound.
pub fn redact_public_text(input: &str) -> String { /* current body without truncate_utf8 */ }

pub fn sanitize_public_text(input: &str) -> String {
    truncate_utf8(redact_public_text(input).trim(), MAX_SAFE_MESSAGE_BYTES)
}

Then use it here:

-use crate::errors::sanitize_public_text;
+use crate::errors::redact_public_text;
 
 pub fn error(message: impl AsRef<str>) {
-    log::error!("{}", sanitize_public_text(message.as_ref()));
+    log::error!("{}", redact_public_text(message.as_ref()));
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@desktop/src-tauri/src/logging.rs` around lines 18 - 20, Split redaction from
public-message truncation in sanitize_public_text: add the exposed
redact_public_text helper in errors.rs containing the existing redaction and
control-character logic without truncate_utf8, then have sanitize_public_text
reuse it before trimming and applying MAX_SAFE_MESSAGE_BYTES. Update
logging::error to call redact_public_text so log records retain the full
redacted message.
desktop/src-tauri/src/links.rs-89-96 (1)

89-96: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Reject backslash traversal in product routes.

valid_product_path only checks / segments, so compozyos://open/..\..\etc passes and navigate_pending sets target.set_path(route) with /..\..\etc. Reject segments containing traversal components, and include a case-insensitive normalized traversal check that also covers backslashes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@desktop/src-tauri/src/links.rs` around lines 89 - 96, Update
valid_product_path to reject traversal components separated by either forward or
backslashes, including case-insensitive normalized forms, before
navigate_pending passes the route to target.set_path. Preserve rejection of ".",
"..", and NUL-containing segments while ensuring paths such as backslash-based
..\..\ traversal cannot pass validation.
desktop/src-tauri/src/errors.rs-170-184 (1)

170-184: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

The response-body assertion cannot fail.

body is never passed to ShellError::new. The message argument is a fixed literal. The assertion !error.safe_message.contains(body) therefore holds regardless of production behavior, so the test cannot detect a regression that embeds a response body. Pass the body through the constructor to make the invariant executable.

💚 Proposed fix
         let body = "claim_token=compozy_claim_response-secret";
         let error = ShellError::new(
             ShellErrorCode::RuntimeUnhealthy,
-            "The runtime returned an invalid response.",
+            format!("The runtime returned an invalid response. {body}"),
             PathBuf::from("/tmp/compozy.log"),
         );
 
         assert!(!error.safe_message.contains(body));
-        assert_eq!(
-            error.safe_message,
-            "The runtime returned an invalid response."
-        );
+        assert!(!error.safe_message.contains("compozy_claim_"));
+        assert!(error.safe_message.starts_with("The runtime returned an invalid response."));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@desktop/src-tauri/src/errors.rs` around lines 170 - 184, Update the test
should_never_embed_http_response_body so the response body variable is passed
into the ShellError construction path, exercising the production behavior that
sanitizes or excludes it from safe_message. Preserve the existing assertions
that safe_message omits the body and equals the expected generic message.
desktop/src-tauri/tests/update_system.rs-464-487 (1)

464-487: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

The test name claims a case the body does not cover.

The name states the malformed and missing feeds stay "distinct from up to date". The loop asserts only FeedInvalid and FeedNotFound. No case establishes that an up-to-date feed yields a different, non-Failed event. The stated invariant is therefore only half enforced. Add the up-to-date case, or rename the test to match the assertions.

💚 Proposed addition
 #[test]
 fn should_keep_malformed_and_missing_tauri_feeds_distinct_from_up_to_date() {
+    // Establish the contrast case first: a valid feed at the current version is not a failure.
+    let current = app_feed(b"signed app update", b"signed app update");
+    let current_directory = tempfile::tempdir().expect("temp directory opens");
+    let (_current_app, current_updater) = mock_app_updater(
+        current.0.origin.join("feed.json").expect("feed URL joins"),
+        current.1,
+        current_directory.path().join("app-update-intent.json"),
+    );
+    assert!(
+        !matches!(current_updater.check(true), AppUpdateEvent::Failed { .. }),
+        "a valid feed must not report a failure"
+    );
     for (routes, expected) in [

Adjust the contrast case to the version the updater treats as current.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@desktop/src-tauri/tests/update_system.rs` around lines 464 - 487, Update
should_keep_malformed_and_missing_tauri_feeds_distinct_from_up_to_date to
include a fixture containing the updater’s current-version feed, and assert that
updater.check(true) returns the corresponding non-failure up-to-date event. Keep
the existing FeedInvalid and FeedNotFound assertions, using the version value
the updater recognizes as current for the contrast case.
desktop/src-tauri/src/runtime/artifacts.rs-263-280 (1)

263-280: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The IPv6 loopback literal never matches host_str.

Url::host_str serializes IPv6 hosts with brackets, so http://[::1]... returns "[::1]", not "::1". Add the bracketed arm so loopback fixture URLs can pass validation.

🐛 Proposed fix
     let local_fixture = artifact.url.scheme() == "http"
         && matches!(
             artifact.url.host_str(),
-            Some("localhost" | "127.0.0.1" | "::1")
+            Some("localhost" | "127.0.0.1" | "[::1]")
         );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@desktop/src-tauri/src/runtime/artifacts.rs` around lines 263 - 280, Update
the local_fixture host match in validate_artifact to accept the bracketed IPv6
loopback value "[::1]" returned by Url::host_str, while preserving the existing
localhost and 127.0.0.1 cases.
internal/cli/app_platform.go-53-63 (1)

53-63: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Do not fail discovery when the macOS version read fails.

mdfind already proved the bundle exists. If defaults read fails, for example when CFBundleShortVersionString is absent, the function returns an error and the app command reports a failure instead of an installed app.

🐛 Proposed fix
-	versionOutput, err := run(
+	versionOutput, versionErr := run(
 		ctx,
 		"defaults",
 		"read",
 		filepath.Join(bundlePath, "Contents", "Info"),
 		"CFBundleShortVersionString",
 	)
-	if err != nil {
-		return appInstallation{}, fmt.Errorf("read macOS app version: %w", err)
+	if versionErr != nil {
+		return appInstallation{Installed: true}, nil
 	}
 	return appInstallation{Installed: true, Version: strings.TrimSpace(versionOutput)}, nil
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cli/app_platform.go` around lines 53 - 63, Update the macOS app
discovery flow around the version read in the relevant installation-discovery
function so a failure from defaults does not return an error after mdfind has
confirmed the bundle exists. Return an installed app with an appropriate empty
or unavailable version when reading CFBundleShortVersionString fails, while
preserving the trimmed version for successful reads.
desktop/src-tauri/src/runtime/mutation.rs-46-61 (1)

46-61: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Declare the minimum Rust version for std::fs::File::try_lock.

desktop/src-tauri/Cargo.toml only declares edition = "2024" and no rust-version, while this code uses File::try_lock(), File::unlock(), and std::fs::TryLockError. Add a rust-version toolchain requirement of Rust 1.89 or newer so the MSRV is documented and enforced.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@desktop/src-tauri/src/runtime/mutation.rs` around lines 46 - 61, Declare
rust-version = "1.89" or newer in the desktop/src-tauri Cargo.toml package
metadata to document and enforce the MSRV required by File::try_lock,
File::unlock, and TryLockError usage in acquire.
desktop/src-tauri/src/runtime/provision.rs-329-344 (1)

329-344: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Avoid reporting "0 bytes" for disk-space failures.

map_io receives required = 0 from the create_dir_all, sync_all, rename, sha256_file, and remove_file call sites (lines 165-182, 235, 253, 311). If those calls fail with ENOSPC, shell_error renders "The runtime needs 0 bytes of free space." Make the required size optional so the message omits an unknown amount.

🐛 Proposed fix
-    #[error("runtime staging needs {required} bytes of free space")]
-    DiskSpace { required: u64 },
+    #[error("runtime staging ran out of free space")]
+    DiskSpace { required: Option<u64> },
-        ProvisionError::DiskSpace { required } => ShellError::new(
-            ShellErrorCode::ProvisionDiskSpace,
-            format!("The runtime needs {required} bytes of free space."),
-            home.app_log.clone(),
-        ),
+        ProvisionError::DiskSpace { required } => ShellError::new(
+            ShellErrorCode::ProvisionDiskSpace,
+            match required {
+                Some(bytes) => format!("The runtime needs {bytes} bytes of free space."),
+                None => "The runtime installation ran out of free disk space.".to_owned(),
+            },
+            home.app_log.clone(),
+        ),

Then pass None from the call sites that do not know the size and Some(size) from write_executable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@desktop/src-tauri/src/runtime/provision.rs` around lines 329 - 344, Update
map_io to accept an optional required byte count and return DiskSpace with that
optional value. Pass None from create_dir_all, sync_all, rename, sha256_file,
and remove_file call sites where the size is unknown, while passing Some(size)
from write_executable; ensure shell_error omits the byte amount when it is
unavailable.
desktop/src-tauri/src/runtime/supervisor_tests.rs-356-363 (1)

356-363: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Assert that on_quit sends no signal.

The test name states that quit never signals the runtime, but the assertions only check the daemon record file. A regression that terminates the child on quit still passes. Add an assertion on the recorded terminations.

💚 Proposed test strengthening
     supervisor.on_quit(Some(42));
+    assert_eq!(spawner.terminated.load(Ordering::SeqCst), 0);
     assert_eq!(
         fs::read(&home.daemon_info).expect("daemon record remains"),
         b"durable daemon record"
     );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@desktop/src-tauri/src/runtime/supervisor_tests.rs` around lines 356 - 363,
Strengthen the `on_quit` test by asserting that the supervisor’s recorded
terminations remain empty after calling `supervisor.on_quit(Some(42))`. Keep the
existing daemon-record assertion, and use the test fixture’s existing
termination-recording symbol to verify no runtime signal was sent.
desktop/src-tauri/src/update/app_update_tauri.rs-148-161 (1)

148-161: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Disable legacy Minisign signature support for update payloads.

key.verify(bytes, &signature, true) enables legacy non-prehashed Minisign signatures. This cached payload check only needs the prehashed form, so pass false here to keep the accepted signature form minimal.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@desktop/src-tauri/src/update/app_update_tauri.rs` around lines 148 - 161,
Update verify_signature to pass false as the final argument to
PublicKey::verify, disabling legacy non-prehashed Minisign signatures while
preserving prehashed signature verification.

coderabbitai[bot]
coderabbitai Bot previously approved these changes Aug 10, 2026
pedronauck and others added 17 commits August 10, 2026 11:16
Checkpoint via cy-loop-tasks (iteration 2, phase B mode=tasks).

Co-Authored-By: Codex <[email protected]>
Checkpoint via cy-loop-tasks (iteration 4, phase B mode=tasks).

Co-Authored-By: Codex <[email protected]>
Checkpoint via cy-loop-tasks (iteration 5, phase B mode=tasks).

Hard-cut public product language to CompozyOS, ship desktop documentation and official-skill routing, and enforce the boundary with UT-113.

Co-Authored-By: Codex <[email protected]>
Checkpoint via cy-loop-tasks (iteration 6, phase B mode=tasks).

Co-Authored-By: Codex <[email protected]>
Checkpoint via cy-loop-tasks (iteration 7, phase B mode=tasks).

Planning-only QA checkpoint: 13 APP scenarios, five desktop journeys, and 17 isolated per-platform charters. No QA lab or product process was started.

Co-Authored-By: Codex <[email protected]>
Checkpoint via cy-loop-tasks (iteration 10, phase D review round 1).

Co-Authored-By: Codex <[email protected]>
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

This branch was successfully deployed

1 active deployment
Preview — 0b749b7a Deployed Aug 10, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant