Skip to content

feat: plugins, sync UI, daemon sidecar, tests, release pipeline#2

Merged
anonfedora merged 1 commit into
masterfrom
feature/plugins-sync-sidecar-tests-release
Jul 10, 2026
Merged

feat: plugins, sync UI, daemon sidecar, tests, release pipeline#2
anonfedora merged 1 commit into
masterfrom
feature/plugins-sync-sidecar-tests-release

Conversation

@anonfedora

@anonfedora anonfedora commented Jul 10, 2026

Copy link
Copy Markdown
Owner

Title: feat: plugins, sync UI, daemon sidecar, tests, release pipeline


Summary

This PR completes the remaining feature work and infrastructure needed for a v0.1.0 release. It closes the gaps between what the app claimed to support and what was actually wired up.


What's changed

WASM Plugin System

  • clipboard-plugin crate: PluginManager using wasmtime — load/unload .wasm files, run them against text clipboard items in sequence
  • Daemon auto-loads all .wasm files from <data-dir>/plugins/ on startup
  • IPC messages: ListPlugins, LoadPlugin, UnloadPlugin — fully handled in daemon
  • Three new Tauri commands: list_plugins, load_plugin, unload_plugin
  • Plugin Manager UI: native file picker (.wasm filter), lists loaded plugins with name/path/status badge, unload button per plugin, refresh

Cross-device Sync

  • clipboard-sync crate: SyncClient with push(since), pull(since), sync_once(), start_background() — push/pull over HTTP against any server running the OpenPaste API
  • clipboard-api crate: now has a runnable binary (cargo run --bin clipboard-api) with --addr flag and OPENPASTE_API_ADDR env var; shares the daemon's SQLite database
  • IPC messages: GetSyncConfig, SetSyncConfig, SyncNow — fully handled in daemon
  • Three new Tauri commands: get_sync_config, set_sync_config, sync_now
  • Sync UI: server URL, optional API token, enable toggle, Save Settings, Sync Now (with spinner), last-synced timestamp, success/error banners

Daemon Sidecar

  • Desktop app probes the IPC socket at startup; if the daemon isn't running, it finds and spawns the binary automatically (checks next to the exe for release builds, walks up to target/debug/ for dev)
  • Child handle stored in Tauri state and killed on app quit
  • If daemon is already running (manual dev workflow), spawn is skipped

Test Coverage

  • clipboard-db: 13 integration tests using Database::in_memory() — insert/get/delete, pin/fav, dedup (UNIQUE constraint), settings upsert, tag CRUD, item-tag assignment, tag filter, FTS5 search, cleanup by age, max-items enforcement
  • clipboard-encryption: 12 unit tests — encrypt/decrypt roundtrip, nonce uniqueness, wrong key rejection, AEAD tamper detection, empty/large input, key derivation determinism, verify correct/wrong password

ClipboardManager — no longer a stub

  • clipboard-core's ClipboardManager::capture() and set() now implemented using arboard directly; includes max-size enforcement and image type rejection with a clear error

Code quality

  • Removed unused imports in clipboard-plugin (Instance, Memory, error) and clipboard-api (delete routing import)
  • Fixed FnOnce closure bug in clipboard-plugin's wasmtime func_wrap (was moving plugin_name out, making it non-Fn)
  • Added missing base64 dep to clipboard-sync, uuid+chrono to clipboard-daemon

CI / Release pipeline

  • CI (ci.yml): modernized to dtolnay/rust-toolchain + Swatinem/rust-cache; split into test (cargo test + clippy + fmt, workspace minus desktop) and build-desktop (cargo check on Tauri crate)
  • Release (release.yml): triggers on v* tags; matrix of 4 targets — macOS arm64 (.dmg), macOS x64 (.dmg), Windows x64 (.exe/.msi), Linux x64 (.deb/.AppImage); uses tauri-apps/tauri-action; creates a draft release; optional code-signing secrets wired in

Documentation

  • README fully rewritten: per-platform install instructions (Gatekeeper/SmartScreen bypass notes), CLI reference, plugin contract, sync relay server setup, developer guide, release instructions
  • CHANGELOG.md created
  • Two app screenshots added to .github/assets/

Testing

# All 26 tests pass
cargo test --workspace --exclude openpaste-desktop

# Daemon runs
cargo run --bin openpaste-daemon

# Sync relay server runs
cargo run --bin clipboard-api

# Desktop app (with hot reload)
cd apps/desktop && npm run tauri:dev

Summary by CodeRabbit

  • New Features
    • Added clipboard capture, image support, history search, tags, favorites, pinning, and retention controls.
    • Added encrypted vault management with password protection, locking, and unlocking.
    • Added desktop settings, system tray access, global shortcuts, launch-at-login, plugins, and synchronization.
    • Added a fully functional CLI for listing, searching, copying, managing, and deleting clipboard items.
    • Added HTTP sync endpoints and cross-device synchronization support.
  • Documentation
    • Reworked installation, CLI, sync, plugins, development, and release documentation.
    • Added a structured changelog.
  • Release Improvements
    • Automated desktop builds and tagged releases across major platforms.

- Plugin Manager: WASM plugin system (wasmtime), auto-load from plugins dir,
  load/unload UI with file picker, list loaded plugins
- Sync: HTTP push/pull sync client and relay server (clipboard-api now has
  a runnable binary), sync config UI (server URL, token, enable toggle,
  Sync Now with status feedback)
- Daemon sidecar: desktop app probes socket on launch, spawns daemon
  automatically if not running, kills it on quit
- Tests: 26 tests across clipboard-db (13) and clipboard-encryption (12)
- ClipboardManager: implemented in clipboard-core using arboard (no longer stub)
- Unused imports cleaned up across clipboard-plugin and clipboard-api
- CI: modernized to dtolnay/rust-toolchain + Swatinem/rust-cache
- Release workflow: builds macOS arm64+x64 .dmg, Windows .exe/.msi,
  Linux .deb/.AppImage on version tags via tauri-action
- README: full rewrite with install instructions, CLI reference, plugin
  contract, sync setup, developer guide, screenshot
- CHANGELOG.md added
@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The change expands OpenPaste with encrypted clipboard persistence, tags, plugins, HTTP synchronization, desktop controls, CLI commands, image handling, API endpoints, and automated CI/release workflows.

Changes

OpenPaste feature integration

Layer / File(s) Summary
Persistence and clipboard foundations
crates/clipboard-db/*, crates/clipboard-core/*, crates/clipboard-platform/*, crates/clipboard-encryption/*
Database models and queries now store encryption metadata, settings, tags, and retention data. Clipboard providers support text and images, while encryption and key-derivation tests cover core cases.
IPC daemon services
crates/clipboard-ipc/*, crates/clipboard-daemon/*, crates/clipboard-plugin/*, crates/clipboard-sync/*
IPC contracts and daemon handlers add vault, settings, tags, plugins, synchronization, encrypted history, notifications, and cleanup. WASM plugin execution and HTTP sync client behavior are implemented.
HTTP API relay
crates/clipboard-api/*
A database-backed Axum server exposes status, clipboard, history, search, item, deletion, and synchronization endpoints, with a new binary entrypoint.
Desktop application workflows
apps/desktop/src-tauri/*, apps/desktop/src/App.tsx, apps/desktop/src/index.css
The Tauri backend and React UI add vault, tags, plugins, sync, image rendering, settings, tray behavior, global shortcuts, launch-at-login, and daemon lifecycle handling.
CLI and project delivery
apps/cli/*, .github/workflows/*, README.md, CHANGELOG.md
The CLI now performs asynchronous IPC-backed listing, search, retrieval, copy, pin, favorite, delete, and status commands. Documentation, changelog, CI, and tagged multi-platform release automation are added or updated.

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

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant DesktopApp
  participant ClipboardDaemon
  participant Database
  participant SyncRelay
  User->>DesktopApp: configure vault, tags, plugins, or sync
  DesktopApp->>ClipboardDaemon: invoke IPC command
  ClipboardDaemon->>Database: read or update application state
  ClipboardDaemon->>SyncRelay: push or pull synchronized items
  ClipboardDaemon-->>DesktopApp: return state or operation result
  DesktopApp-->>User: render clipboard history and settings
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately highlights several major changes in the PR, including plugins, sync UI, daemon sidecar, tests, and release pipeline.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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 feature/plugins-sync-sidecar-tests-release

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.

@anonfedora anonfedora self-assigned this Jul 10, 2026
@anonfedora anonfedora merged commit c5bbca7 into master Jul 10, 2026
1 of 7 checks passed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 10

Note

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

🟡 Minor comments (12)
crates/clipboard-api/src/api.rs-95-98 (1)

95-98: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

total field returns page count, not total database count.

"total": out.len() returns the number of items in the current page, not the total count of all items in the database. Conventionally, total in a paginated response means the total item count for pagination. A client requesting limit=50&offset=0 with 1000 items in the DB would see total: 50 and stop paginating.

Rename to count or returned to avoid confusion, or add a COUNT query for the true total.

🩹 Proposed fix: rename to `count`
-            Json(json!({ "items": out, "total": out.len() }))
+            Json(json!({ "items": out, "count": out.len() }))
🤖 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 `@crates/clipboard-api/src/api.rs` around lines 95 - 98, In the item-list
response built by the list endpoint, replace the misleading "total" JSON field
derived from out.len() with "count" (or "returned") to clearly represent the
number of items in the current page; update any related response handling or
tests accordingly.
crates/clipboard-sync/src/sync.rs-98-131 (1)

98-131: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Push silently caps at 1000 items and filters in memory.

list_items(1000, 0) fetches only the 1000 most recent rows before the since filter is applied in memory. On an initial full sync (since = None), any device with more than 1000 items will never push the older ones. Consider pushing in paged batches, or pushing directly from a created_at > since query so the DB does the filtering and bounding.

🤖 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 `@crates/clipboard-sync/src/sync.rs` around lines 98 - 131, Update
SyncService::push to avoid the fixed list_items(1000, 0) limit: use a database
query that applies the since_rfc3339 created_at filter and paginate through all
matching records, or repeatedly fetch batches with an advancing offset/cursor.
Ensure every matching item is transformed into SyncItem and pushed, including
older records during a full sync, while preserving the existing return count and
empty-result behavior.
crates/clipboard-db/src/database.rs-199-246 (1)

199-246: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Retention/limit maintenance can delete pinned or favorite items.

Both cleanup_old_items (age-based) and enforce_max_items (count-based, oldest created_at first) delete rows without excluding pinned/favorite items. Users typically expect pinned items to survive automatic pruning; silently deleting them is data loss.

🛡️ Suggested guard (exclude pinned/favorite)
-        let result = sqlx::query(
-            "DELETE FROM clipboard_items WHERE created_at < ?"
-        )
+        let result = sqlx::query(
+            "DELETE FROM clipboard_items WHERE created_at < ? AND pinned = 0 AND favorite = 0"
+        )

Apply an equivalent WHERE pinned = 0 AND favorite = 0 filter to the enforce_max_items subquery as well.

🤖 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 `@crates/clipboard-db/src/database.rs` around lines 199 - 246, Update
cleanup_old_items and enforce_max_items so automatic deletion excludes protected
rows by adding pinned = 0 AND favorite = 0 to their DELETE conditions, including
the enforce_max_items ID-selection subquery; preserve existing retention and
count behavior for unprotected items.
crates/clipboard-core/src/clipboard.rs-57-69 (1)

57-69: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Avoid silent data corruption with from_utf8_lossy.

String::from_utf8_lossy silently replaces invalid UTF-8 sequences with U+FFFD, which corrupts non-UTF-8 content written to the clipboard without any error signal. Use std::str::from_utf8 and return an error on failure so callers can handle the case explicitly.

🛡️ Proposed fix
 pub async fn set(&self, item: &ClipboardItem) -> Result<(), ClipboardError> {
     match item.content_type {
         ContentType::Image => return Err(ClipboardError::UnsupportedContentType),
         _ => {}
     }

-    let text = String::from_utf8_lossy(&item.content).to_string();
+    let text = std::str::from_utf8(&item.content)
+        .map_err(|e| ClipboardError::AccessFailed(format!("Invalid UTF-8 content: {}", e)))?;
     let mut ctx = arboard::Clipboard::new()
         .map_err(|e| ClipboardError::AccessFailed(e.to_string()))?;

     ctx.set_text(&text)
         .map_err(|e| ClipboardError::AccessFailed(e.to_string()))
 }
🤖 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 `@crates/clipboard-core/src/clipboard.rs` around lines 57 - 69,
ClipboardCore::set currently replaces invalid UTF-8 via String::from_utf8_lossy,
silently corrupting clipboard content. Replace it with std::str::from_utf8 and
map validation failures to the appropriate ClipboardError, then pass the
validated string or str reference to ctx.set_text while preserving existing
access-error handling.
crates/clipboard-daemon/src/main.rs-561-577 (1)

561-577: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

SetSyncConfig cannot clear an API token and swallows its save error.

When api_token is None the stored sync_api_token is left intact, so a user can never remove a previously saved token. Additionally the token upsert uses let _ =, so its failure is excluded from the results success check and reported as success.

🔧 Suggested handling
-                        let results = vec![
-                            database_clone.upsert_setting("sync_server_url", &server_url).await,
-                            database_clone.upsert_setting("sync_enabled", &enabled.to_string()).await,
-                        ];
-                        if let Some(token) = &api_token {
-                            let _ = database_clone.upsert_setting("sync_api_token", token).await;
-                        }
+                        let mut results = vec![
+                            database_clone.upsert_setting("sync_server_url", &server_url).await,
+                            database_clone.upsert_setting("sync_enabled", &enabled.to_string()).await,
+                        ];
+                        // Persist an empty string to clear the token when None
+                        results.push(
+                            database_clone
+                                .upsert_setting("sync_api_token", api_token.as_deref().unwrap_or(""))
+                                .await,
+                        );
🤖 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 `@crates/clipboard-daemon/src/main.rs` around lines 561 - 577, Update the
SetSyncConfig handler to always persist the API token setting, using an
appropriate empty or cleared value when api_token is None, so existing tokens
can be removed. Include the token upsert result in the results collection rather
than discarding it with let _, ensuring any failure returns IpcMessage::Error.
crates/clipboard-daemon/src/main.rs-679-707 (1)

679-707: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Loaded plugins are never applied to captured clipboard content crates/clipboard-daemon/src/main.rs:679-707

PluginManager::process() is never called on the capture path, so auto-loaded WASM plugins currently have no effect on what gets persisted. Call it before encrypting/storing if this pipeline is intended.

🤖 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 `@crates/clipboard-daemon/src/main.rs` around lines 679 - 707, Loaded plugins
are not applied before captured content is persisted. In the capture/save
pipeline containing the encryption block and db_item construction, invoke
PluginManager::process() on item.content before encryption, use the processed
content for encryption and storage, and handle any processing errors according
to the existing error-handling conventions.
.github/workflows/ci.yml-22-23 (1)

22-23: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Set persist-credentials: false on checkout steps.

actions/checkout@v4 persists the GITHUB_TOKEN in .git/config by default. Neither CI job pushes back to the repository, so disabling credential persistence reduces the attack surface if a later step is compromised.

🔒 Proposed fix (apply to both checkout steps)
       - name: Checkout
         uses: actions/checkout@v4
+        with:
+          persist-credentials: false

Also applies to: 66-67

🤖 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 @.github/workflows/ci.yml around lines 22 - 23, Update both checkout steps
using actions/checkout@v4 in the CI workflow to set persist-credentials: false,
preventing the GITHUB_TOKEN from being stored in the repository’s Git
configuration.

Source: Linters/SAST tools

apps/cli/src/main.rs-188-194 (1)

188-194: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Normalize ID comparison across all CLI commands.

Get and Copy compare IDs as strings (i.id == id), while Pin, Favorite, and Delete parse to i64 first. A user passing openpaste get 01 would fail to find item 1, but openpaste pin 01 would succeed. Parse to i64 consistently in all commands.

🔧 Proposed fix for `Get` and `Copy` ID matching
         Commands::Get { id } => {
+            let _id_i64: i64 = id.parse().map_err(|_| anyhow!("Invalid ID: {}", id))?;
             // Fetch full list and find by ID (no single-item IPC yet)
             let c = client().await;
             match c.send(IpcMessage::GetHistory).await {
                 Ok(IpcMessage::ClipboardHistory { items }) => {
-                    let item = items.iter().find(|i| i.id == id)
+                    let item = items.iter().find(|i| i.id.parse::<i64>().ok() == Some(_id_i64))
                         .ok_or_else(|| anyhow!("Item {} not found", id))?;

Apply the same pattern to Copy (line 216).

Also applies to: 211-218, 236-237

🤖 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 `@apps/cli/src/main.rs` around lines 188 - 194, Normalize ID handling across
the CLI command handlers: in Get and Copy, parse the user-provided id to i64
before searching and compare against each item’s numeric ID, matching the
existing behavior in Pin, Favorite, and Delete. Update the relevant find
predicates and preserve appropriate parse-error handling so inputs such as “01”
resolve consistently.
.github/workflows/release.yml-49-50 (1)

49-50: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Set persist-credentials: false on the checkout step.

Same as the CI workflow — actions/checkout@v4 persists GITHUB_TOKEN in .git/config by default. The release workflow doesn't push commits, so disabling persistence is best practice.

🔒 Proposed fix
       - name: Checkout
         uses: actions/checkout@v4
+        with:
+          persist-credentials: false
🤖 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 @.github/workflows/release.yml around lines 49 - 50, Update the “Checkout”
step using actions/checkout@v4 to set persist-credentials: false, matching the
CI workflow and preventing GITHUB_TOKEN from being stored in .git/config.

Source: Linters/SAST tools

README.md-30-30 (1)

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

Replace placeholder YOUR_USERNAME in the releases URL.

The installation section links to https://github.com/YOUR_USERNAME/openpaste/releases, which is a placeholder. Users following this link will get a 404.

📝 Proposed fix
-Download the correct package for your system from the latest [GitHub Releases](https://github.com/YOUR_USERNAME/openpaste/releases).
+Download the correct package for your system from the latest [GitHub Releases](https://github.com/coderabbitai/openpaste/releases).
🤖 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 `@README.md` at line 30, Replace the placeholder owner in the README
installation section’s GitHub Releases URL with the repository’s actual GitHub
username or organization, ensuring the link points to the valid openpaste
releases page.
README.md-112-113 (1)

112-113: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use %LOCALAPPDATA% for the Windows plugins path.
crates/clipboard-daemon/src/main.rs loads plugins from dirs::data_local_dir().join("openpaste").join("plugins"), so the README should match that Windows location instead of %APPDATA%\openpaste\plugins\.

🤖 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 `@README.md` around lines 112 - 113, Update the Windows plugins path in the
README to use `%LOCALAPPDATA%\openpaste\plugins\` instead of
`%APPDATA%\openpaste\plugins\`, matching the location derived by
`dirs::data_local_dir()` in the plugin-loading logic.
apps/cli/src/main.rs-197-200 (1)

197-200: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Write image bytes directly (apps/cli/src/main.rs:197-200)

item.content already holds raw bytes from IPC, so base64-decoding here can corrupt image payloads that happen to be valid base64. Write item.content directly.

🤖 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 `@apps/cli/src/main.rs` around lines 197 - 200, In the image output handling
near the stdout write, remove the STANDARD base64 decode and fallback logic, and
write item.content directly with std::io::stdout().write_all(&item.content)?;
clean up any now-unused base64 imports.
🧹 Nitpick comments (8)
crates/clipboard-api/src/main.rs (1)

27-31: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use a proper argument parser instead of manual std::env::args parsing.

The current parsing doesn't handle --addr=value syntax, provides no --help output, and silently falls through to the env var or default if --addr is passed without a value. Adding clap would provide structured parsing, help text, and validation for free.

♻️ Proposed refactor using clap
+use clap::Parser;
+
+#[derive(Parser)]
+#[command(about = "OpenPaste sync relay server")]
+struct Args {
+    /// Address to bind the API server
+    #[arg(long, env = "OPENPASTE_API_ADDR", default_value = "127.0.0.1:8080")]
+    addr: SocketAddr,
+}

 #[tokio::main]
 async fn main() -> Result<()> {
+    let args = Args::parse();
+
     // Basic logging
     tracing_subscriber::fmt()
         .with_env_filter(
             tracing_subscriber::EnvFilter::from_default_env()
                 .add_directive(tracing::Level::INFO.into()),
         )
         .init();

-    // Resolve bind address — flag > env > default
-    let addr_str = std::env::args()
-        .skip_while(|a| a != "--addr")
-        .nth(1)
-        .or_else(|| std::env::var("OPENPASTE_API_ADDR").ok())
-        .unwrap_or_else(|| "127.0.0.1:8080".to_string());
-
-    let addr: SocketAddr = addr_str
-        .parse()
-        .map_err(|e| anyhow::anyhow!("Invalid address '{}': {}", addr_str, e))?;
+    let addr = args.addr;

And add clap = { workspace = true, features = ["derive", "env"] } to Cargo.toml.

🤖 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 `@crates/clipboard-api/src/main.rs` around lines 27 - 31, Replace the manual
argument handling in main with a clap-derived argument parser supporting --addr,
--addr=value, automatic --help, and validation for missing or invalid values.
Configure the addr option to read OPENPASTE_API_ADDR as its environment fallback
and retain 127.0.0.1:8080 as the default; add the workspace clap dependency with
derive and env features to Cargo.toml.
crates/clipboard-sync/src/sync.rs (2)

86-92: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Give the HTTP client an explicit timeout.

Client::new() has no request timeout, so a stalled relay server will hang push/pull indefinitely — and since these run inside the start_background loop, a single hung request blocks all future sync cycles for that task.

⏱️ Suggested change
-            http: Client::new(),
+            http: Client::builder()
+                .timeout(std::time::Duration::from_secs(30))
+                .build()
+                .unwrap_or_else(|_| Client::new()),
🤖 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 `@crates/clipboard-sync/src/sync.rs` around lines 86 - 92, Configure an
explicit request timeout when constructing the HTTP client in Syncer::new,
replacing Client::new() with the builder API and a suitable Duration; ensure the
required time import is added. Keep the timeout centralized in the client so
push and pull operations cannot block the start_background loop indefinitely.

210-246: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

One malformed pulled item aborts the entire pull.

The ? on base64 decode (Line 213) and RFC3339 parse (Line 221) propagate an error out of the loop, discarding all remaining (and any already-decoded-but-not-yet-inserted) items. Since insert failures are already tolerated below, prefer skipping the bad item and continuing, mirroring the server-side sync_push reject behavior.

♻️ Suggested resilience change
-            let content = STANDARD
-                .decode(&sync_item.content_b64)
-                .map_err(|e| SyncError::SyncFailed(format!("base64 decode: {}", e)))?;
+            let content = match STANDARD.decode(&sync_item.content_b64) {
+                Ok(c) => c,
+                Err(e) => { error!("skip item {}: base64 decode: {}", sync_item.hash, e); continue; }
+            };
@@
-            let created_at = chrono::DateTime::parse_from_rfc3339(&sync_item.created_at)
-                .map_err(|e| SyncError::SyncFailed(format!("date parse: {}", e)))?
-                .with_timezone(&chrono::Utc);
+            let created_at = match chrono::DateTime::parse_from_rfc3339(&sync_item.created_at) {
+                Ok(dt) => dt.with_timezone(&chrono::Utc),
+                Err(e) => { error!("skip item {}: date parse: {}", sync_item.hash, e); 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 `@crates/clipboard-sync/src/sync.rs` around lines 210 - 246, Update the
pull-item processing loop to skip malformed items instead of returning from the
entire pull operation: in the base64 decoding and RFC3339 parsing within the
sync method, handle errors by logging or otherwise recording the invalid item
and continuing to the next item. Remove the `?` propagation for these per-item
conversions while preserving successful item insertion and existing
duplicate-insert handling.
crates/clipboard-db/src/database.rs (1)

180-197: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

value_type is always written as 'string' and updated_at stores epoch-millis into a TEXT column.

value_type is hardcoded, so the newly added column carries no information — either populate it from the actual value kind or drop it until needed. Separately, strftime('%s','now') * 1000 writes a numeric epoch into updated_at TEXT, which diverges from the RFC3339 text convention used by clipboard_items.created_at. Not a functional bug (SQLite type affinity coerces it), but the inconsistency will bite anyone parsing timestamps uniformly later.

🤖 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 `@crates/clipboard-db/src/database.rs` around lines 180 - 197, Update
upsert_setting to avoid hardcoding value_type as 'string': either derive and
persist the actual setting value kind or remove the column usage until
supported. Also make updated_at consistent with the RFC3339 timestamp convention
used by clipboard_items.created_at, replacing the epoch-millisecond SQLite
expression with the project’s standard RFC3339 generation and preserving that
format in both insert and conflict-update paths.
crates/clipboard-daemon/src/main.rs (1)

51-52: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Dead placeholder key.

_key is now unused since the vault holds None until UnlockVault derives the real key. Drop it to avoid confusion.

🤖 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 `@crates/clipboard-daemon/src/main.rs` around lines 51 - 52, Remove the unused
placeholder `_key` declaration near the `encryption` initialization in `main`,
leaving vault key derivation to `UnlockVault` and retaining only the required
encryption state setup.
apps/cli/src/main.rs (1)

150-152: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract repeated IPC error-handling into a helper.

The same three error arms (Daemon error, IPC error, Unexpected response) are duplicated across all seven command handlers. A small helper that filters error responses would eliminate this repetition.

♻️ Proposed helper and usage example
/// Maps IPC transport/daemon errors to `anyhow::Error`, passing through successful responses.
fn check_ipc(result: Result<IpcMessage, String>) -> Result<IpcMessage> {
    match result {
        Ok(IpcMessage::Error { message }) => Err(anyhow!("Daemon error: {}", message)),
        Err(e) => Err(anyhow!("IPC error: {}", e)),
        Ok(other) => Ok(other),
    }
}

Usage in each command:

-            match c.send(IpcMessage::GetHistory).await {
-                Ok(IpcMessage::ClipboardHistory { items }) => { /* ... */ }
-                Ok(IpcMessage::Error { message }) => return Err(anyhow!("Daemon error: {}", message)),
-                Err(e) => return Err(anyhow!("IPC error: {}", e)),
-                _ => return Err(anyhow!("Unexpected response")),
+            match check_ipc(c.send(IpcMessage::GetHistory).await)? {
+                IpcMessage::ClipboardHistory { items } => { /* ... */ }
+                _ => return Err(anyhow!("Unexpected response")),
             }

Also applies to: 182-184, 205-207, 220-222, 230-232, 243-245, 256-258, 269-271

🤖 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 `@apps/cli/src/main.rs` around lines 150 - 152, Extract the repeated IPC error
handling into a shared check_ipc helper near the command handlers, mapping
daemon and transport errors to anyhow errors while passing through successful
IpcMessage values. Update all seven command handlers, including the branches
around the existing handlers, to call check_ipc before matching responses and
remove their duplicated Daemon error, IPC error, and Unexpected response arms.
apps/desktop/src/App.tsx (1)

246-259: 🚀 Performance & Scalability | 🔵 Trivial

N+1 IPC round-trips when loading item tags.

loadAllItemTags issues one get_item_tags invoke per item, and it runs on every items change (Line 389-394). With auto-refresh replacing items frequently, a list of N entries generates N daemon round-trips each cycle. Consider adding a batched daemon/IPC endpoint (e.g. get_tags_for_items(ids) returning a Record<id, Tag[]>) and calling it once per refresh to cut this to a single request.

🤖 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 `@apps/desktop/src/App.tsx` around lines 246 - 259, Replace the per-item
invokes in loadAllItemTags with a single batched get_tags_for_items IPC call
accepting all item IDs and returning a Record<number, Tag[]>; update the result
handling and error behavior accordingly, and add the corresponding daemon/IPC
endpoint and wiring so the existing items-change refresh path performs only one
request.
apps/desktop/src-tauri/Cargo.toml (1)

14-14: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Drop the duplicate global-shortcut feature

tauri 1.5.0 exposes both global-shortcut and global-shortcut-all, and global-shortcut-all already includes global-shortcut. Remove the extra entry unless it needs to be enabled independently.

🤖 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 `@apps/desktop/src-tauri/Cargo.toml` at line 14, Remove the redundant
"global-shortcut" entry from the tauri dependency features list in Cargo.toml,
retaining "global-shortcut-all" and all other required features.
🤖 Prompt for all review comments with 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.

Inline comments:
In @.github/workflows/release.yml:
- Line 31: Update the Intel macOS matrix entry in the release workflow from
macos-13 to macos-15-intel so the x64 release job uses an available
GitHub-hosted runner.

In `@apps/desktop/src/App.tsx`:
- Around line 404-411: Guard the interval setup in the useEffect containing
loadClipboardHistory so a zero or empty settings.refreshInterval cannot create a
busy loop. Clamp the configured value to a sane positive minimum before
multiplying by 1000, while preserving the existing cleanup and dependency
behavior.
- Around line 466-475: Update the handleKeyDown global keyboard handler in the
App useEffect to return immediately whenever document.activeElement is any
editable control, including inputs, textareas, selects, contenteditable
elements, and the existing search input Escape behavior. Preserve search-field
blur/query clearing for Escape while preventing shortcut handling and
preventDefault from affecting all other editable fields.

In `@crates/clipboard-api/src/api.rs`:
- Around line 271-272: Update sync_pull to avoid truncating synchronization at
500 records: preferably pass the since timestamp into the database query used by
list_items, or add pagination so all items newer than since are retrieved and
processed. Ensure the endpoint continues fetching until the complete result set
is synchronized.
- Around line 299-305: Update item_to_json to preserve binary clipboard data:
keep encrypted content masked, return valid UTF-8 content as text, and
base64-encode content that is not valid UTF-8 (or is identified as binary by
content_type). Ensure all regular API endpoints using item_to_json return
lossless content consistent with the sync endpoints.
- Around line 47-58: Add bearer-token authentication middleware to the API
router in router, validating the token sent by sync.rs against configured server
credentials and rejecting missing or invalid tokens before reaching clipboard,
history, item, and sync handlers. Replace CorsLayer::permissive() with an
allowlist of explicitly configured trusted origins, preserving only necessary
methods and headers; ensure configuration is threaded through AppState or router
setup and update the 0.0.0.0 example as appropriate.
- Around line 255-257: Update sync_push’s state.db.insert_item handling to
accept only successful inserts and known UNIQUE-constraint violations, following
the error classification used by set_clipboard. Propagate or return all other
database errors instead of adding their hashes to accepted, while preserving
duplicate items as accepted.

In `@crates/clipboard-platform/src/provider.rs`:
- Around line 76-131: Wrap the entire Objective-C probe in pasteboard_has_image
with objc::rc::autoreleasepool, returning the existing boolean result from its
closure. Keep all Cocoa object access inside the pool so temporary objects are
drained on every poll, while preserving the current null checks and image-type
detection logic.

In `@crates/clipboard-plugin/src/plugin.rs`:
- Around line 219-230: Validate the guest-provided result_len against the
available linear memory before slicing in the transformation flow around
process_fn.call. After handling nonpositive values, compare result_len as a
safely converted usize with memory.data(&store).len(); return an appropriate
PluginError::ExecutionFailed for oversized lengths, and only then create
result_bytes from the bounded slice.
- Around line 52-57: Bound untrusted plugin execution by configuring
interruption support in the constructor `new` and enforcing a per-call limit in
`run_plugin`. Enable Wasmtime fuel or epoch interruption on `Engine`, then reset
and apply the configured budget before each `process_fn.call(...)`, handling
exhaustion as a plugin execution error.

---

Minor comments:
In @.github/workflows/ci.yml:
- Around line 22-23: Update both checkout steps using actions/checkout@v4 in the
CI workflow to set persist-credentials: false, preventing the GITHUB_TOKEN from
being stored in the repository’s Git configuration.

In @.github/workflows/release.yml:
- Around line 49-50: Update the “Checkout” step using actions/checkout@v4 to set
persist-credentials: false, matching the CI workflow and preventing GITHUB_TOKEN
from being stored in .git/config.

In `@apps/cli/src/main.rs`:
- Around line 188-194: Normalize ID handling across the CLI command handlers: in
Get and Copy, parse the user-provided id to i64 before searching and compare
against each item’s numeric ID, matching the existing behavior in Pin, Favorite,
and Delete. Update the relevant find predicates and preserve appropriate
parse-error handling so inputs such as “01” resolve consistently.
- Around line 197-200: In the image output handling near the stdout write,
remove the STANDARD base64 decode and fallback logic, and write item.content
directly with std::io::stdout().write_all(&item.content)?; clean up any
now-unused base64 imports.

In `@crates/clipboard-api/src/api.rs`:
- Around line 95-98: In the item-list response built by the list endpoint,
replace the misleading "total" JSON field derived from out.len() with "count"
(or "returned") to clearly represent the number of items in the current page;
update any related response handling or tests accordingly.

In `@crates/clipboard-core/src/clipboard.rs`:
- Around line 57-69: ClipboardCore::set currently replaces invalid UTF-8 via
String::from_utf8_lossy, silently corrupting clipboard content. Replace it with
std::str::from_utf8 and map validation failures to the appropriate
ClipboardError, then pass the validated string or str reference to ctx.set_text
while preserving existing access-error handling.

In `@crates/clipboard-daemon/src/main.rs`:
- Around line 561-577: Update the SetSyncConfig handler to always persist the
API token setting, using an appropriate empty or cleared value when api_token is
None, so existing tokens can be removed. Include the token upsert result in the
results collection rather than discarding it with let _, ensuring any failure
returns IpcMessage::Error.
- Around line 679-707: Loaded plugins are not applied before captured content is
persisted. In the capture/save pipeline containing the encryption block and
db_item construction, invoke PluginManager::process() on item.content before
encryption, use the processed content for encryption and storage, and handle any
processing errors according to the existing error-handling conventions.

In `@crates/clipboard-db/src/database.rs`:
- Around line 199-246: Update cleanup_old_items and enforce_max_items so
automatic deletion excludes protected rows by adding pinned = 0 AND favorite = 0
to their DELETE conditions, including the enforce_max_items ID-selection
subquery; preserve existing retention and count behavior for unprotected items.

In `@crates/clipboard-sync/src/sync.rs`:
- Around line 98-131: Update SyncService::push to avoid the fixed
list_items(1000, 0) limit: use a database query that applies the since_rfc3339
created_at filter and paginate through all matching records, or repeatedly fetch
batches with an advancing offset/cursor. Ensure every matching item is
transformed into SyncItem and pushed, including older records during a full
sync, while preserving the existing return count and empty-result behavior.

In `@README.md`:
- Line 30: Replace the placeholder owner in the README installation section’s
GitHub Releases URL with the repository’s actual GitHub username or
organization, ensuring the link points to the valid openpaste releases page.
- Around line 112-113: Update the Windows plugins path in the README to use
`%LOCALAPPDATA%\openpaste\plugins\` instead of `%APPDATA%\openpaste\plugins\`,
matching the location derived by `dirs::data_local_dir()` in the plugin-loading
logic.

---

Nitpick comments:
In `@apps/cli/src/main.rs`:
- Around line 150-152: Extract the repeated IPC error handling into a shared
check_ipc helper near the command handlers, mapping daemon and transport errors
to anyhow errors while passing through successful IpcMessage values. Update all
seven command handlers, including the branches around the existing handlers, to
call check_ipc before matching responses and remove their duplicated Daemon
error, IPC error, and Unexpected response arms.

In `@apps/desktop/src-tauri/Cargo.toml`:
- Line 14: Remove the redundant "global-shortcut" entry from the tauri
dependency features list in Cargo.toml, retaining "global-shortcut-all" and all
other required features.

In `@apps/desktop/src/App.tsx`:
- Around line 246-259: Replace the per-item invokes in loadAllItemTags with a
single batched get_tags_for_items IPC call accepting all item IDs and returning
a Record<number, Tag[]>; update the result handling and error behavior
accordingly, and add the corresponding daemon/IPC endpoint and wiring so the
existing items-change refresh path performs only one request.

In `@crates/clipboard-api/src/main.rs`:
- Around line 27-31: Replace the manual argument handling in main with a
clap-derived argument parser supporting --addr, --addr=value, automatic --help,
and validation for missing or invalid values. Configure the addr option to read
OPENPASTE_API_ADDR as its environment fallback and retain 127.0.0.1:8080 as the
default; add the workspace clap dependency with derive and env features to
Cargo.toml.

In `@crates/clipboard-daemon/src/main.rs`:
- Around line 51-52: Remove the unused placeholder `_key` declaration near the
`encryption` initialization in `main`, leaving vault key derivation to
`UnlockVault` and retaining only the required encryption state setup.

In `@crates/clipboard-db/src/database.rs`:
- Around line 180-197: Update upsert_setting to avoid hardcoding value_type as
'string': either derive and persist the actual setting value kind or remove the
column usage until supported. Also make updated_at consistent with the RFC3339
timestamp convention used by clipboard_items.created_at, replacing the
epoch-millisecond SQLite expression with the project’s standard RFC3339
generation and preserving that format in both insert and conflict-update paths.

In `@crates/clipboard-sync/src/sync.rs`:
- Around line 86-92: Configure an explicit request timeout when constructing the
HTTP client in Syncer::new, replacing Client::new() with the builder API and a
suitable Duration; ensure the required time import is added. Keep the timeout
centralized in the client so push and pull operations cannot block the
start_background loop indefinitely.
- Around line 210-246: Update the pull-item processing loop to skip malformed
items instead of returning from the entire pull operation: in the base64
decoding and RFC3339 parsing within the sync method, handle errors by logging or
otherwise recording the invalid item and continuing to the next item. Remove the
`?` propagation for these per-item conversions while preserving successful item
insertion and existing duplicate-insert handling.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8d32cdf0-d592-4432-8ea0-8fc4da156fca

📥 Commits

Reviewing files that changed from the base of the PR and between 97cacff and e6acb9e.

⛔ Files ignored due to path filters (3)
  • .github/assets/screenshot1.png is excluded by !**/*.png
  • .github/assets/screenshot2.png is excluded by !**/*.png
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (31)
  • .github/workflows/ci.yml
  • .github/workflows/release.yml
  • CHANGELOG.md
  • README.md
  • apps/cli/Cargo.toml
  • apps/cli/src/main.rs
  • apps/desktop/src-tauri/Cargo.toml
  • apps/desktop/src-tauri/src/main.rs
  • apps/desktop/src-tauri/tauri.conf.json
  • apps/desktop/src/App.tsx
  • apps/desktop/src/index.css
  • crates/clipboard-api/Cargo.toml
  • crates/clipboard-api/src/api.rs
  • crates/clipboard-api/src/main.rs
  • crates/clipboard-core/Cargo.toml
  • crates/clipboard-core/src/clipboard.rs
  • crates/clipboard-daemon/Cargo.toml
  • crates/clipboard-daemon/src/main.rs
  • crates/clipboard-db/migrations/001_initial.sql
  • crates/clipboard-db/src/database.rs
  • crates/clipboard-db/src/models.rs
  • crates/clipboard-encryption/src/encryption.rs
  • crates/clipboard-encryption/src/key_derivation.rs
  • crates/clipboard-ipc/src/ipc.rs
  • crates/clipboard-ipc/src/lib.rs
  • crates/clipboard-platform/Cargo.toml
  • crates/clipboard-platform/src/provider.rs
  • crates/clipboard-plugin/src/plugin.rs
  • crates/clipboard-sync/Cargo.toml
  • crates/clipboard-sync/src/lib.rs
  • crates/clipboard-sync/src/sync.rs

tauri_args: --target aarch64-apple-darwin

# macOS Intel
- os: macos-13

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check GitHub Actions runner availability for macos-13
# Also verify the latest available Intel macOS runner labels
gh api /repos/{owner}/{repo}/actions/runners --paginate 2>/dev/null | \
  jq '.runners[].labels[].name' 2>/dev/null | sort -u || \
  echo "Unable to query runners — verify manually at https://github.com/actions/runner-images"

Repository: anonfedora/openpaste

Length of output: 158


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the workflow and inspect the relevant job section
ast-grep outline .github/workflows/release.yml --view expanded || true
echo '---'
cat -n .github/workflows/release.yml | sed -n '1,140p'

echo '--- runner label references ---'
rg -n 'macos-13|macos-15-intel|macOS-x64|x86_64-apple-darwin' .github/workflows .github || true

Repository: anonfedora/openpaste

Length of output: 5289


🌐 Web query:

GitHub Actions macOS runner labels macos-13 deprecation current availability macos-15-intel docs

💡 Result:

The macOS 13 GitHub Actions runner image has been fully retired since December 4, 2025 [1][2]. Any workflows pinned to the macos-13 label will fail immediately [2][3]. Regarding the macos-15-intel runner: - Availability: It is currently available as a GitHub-hosted runner [4][5]. - Purpose: It provides an x86_64 (Intel) environment on macOS 15, intended as a replacement for users migrating from the deprecated macos-13 runners [5]. - Support Lifecycle: This runner is scheduled for retirement in August 2027 [2][4][5]. GitHub has stated that after this date, the x86_64 architecture will no longer be supported for GitHub-hosted macOS runners [5]. For workflows requiring Intel-based macOS runners, macos-15-intel is the designated long-term solution until its scheduled retirement [5][6]. Users are otherwise encouraged to migrate to Apple Silicon (ARM64) runners, such as macos-14 or macos-15, where possible [7][2].

Citations:


Replace macos-13 with macos-15-intel for the Intel macOS build
macos-13 is retired, so this matrix entry won’t get a GitHub-hosted runner and the x64 release job will fail to start.

🧰 Tools
🪛 actionlint (1.7.12)

[error] 31-31: label "macos-13" is unknown. available labels are "windows-latest", "windows-latest-8-cores", "windows-2025", "windows-2025-vs2026", "windows-2022", "windows-11-arm", "ubuntu-slim", "ubuntu-latest", "ubuntu-latest-4-cores", "ubuntu-latest-8-cores", "ubuntu-latest-16-cores", "ubuntu-24.04", "ubuntu-24.04-arm", "ubuntu-22.04", "ubuntu-22.04-arm", "macos-latest", "macos-latest-xlarge", "macos-latest-large", "macos-26-intel", "macos-26-xlarge", "macos-26-large", "macos-26", "macos-15-intel", "macos-15-xlarge", "macos-15-large", "macos-15", "macos-14-xlarge", "macos-14-large", "macos-14", "self-hosted", "x64", "arm", "arm64", "linux", "macos", "windows". if it is a custom label for self-hosted runner, set list of labels in actionlint.yaml config file

(runner-label)

🤖 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 @.github/workflows/release.yml at line 31, Update the Intel macOS matrix
entry in the release workflow from macos-13 to macos-15-intel so the x64 release
job uses an available GitHub-hosted runner.

Source: Linters/SAST tools

Comment thread apps/desktop/src/App.tsx
Comment on lines +404 to +411
useEffect(() => {
const interval = setInterval(() => {
if (!searchQuery && activeTagFilter === null) {
loadClipboardHistory(false)
}
}, settings.refreshInterval * 1000)
return () => clearInterval(interval)
}, [searchQuery, settings.refreshInterval, activeTagFilter])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard against a zero (or empty) refresh interval.

settings.refreshInterval is user-editable via parseInt(e.target.value) || 0 (Line 1715), so it can become 0. setInterval(fn, 0) then fires as fast as the event loop allows, turning auto-refresh into a busy loop that continuously hits get_clipboard_history over IPC. Clamp to a sane minimum.

🔧 Proposed fix
   useEffect(() => {
+    const intervalMs = Math.max(1, settings.refreshInterval) * 1000
     const interval = setInterval(() => {
       if (!searchQuery && activeTagFilter === null) {
         loadClipboardHistory(false)
       }
-    }, settings.refreshInterval * 1000)
+    }, intervalMs)
     return () => clearInterval(interval)
   }, [searchQuery, settings.refreshInterval, activeTagFilter])
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useEffect(() => {
const interval = setInterval(() => {
if (!searchQuery && activeTagFilter === null) {
loadClipboardHistory(false)
}
}, settings.refreshInterval * 1000)
return () => clearInterval(interval)
}, [searchQuery, settings.refreshInterval, activeTagFilter])
useEffect(() => {
const intervalMs = Math.max(1, settings.refreshInterval) * 1000
const interval = setInterval(() => {
if (!searchQuery && activeTagFilter === null) {
loadClipboardHistory(false)
}
}, intervalMs)
return () => clearInterval(interval)
}, [searchQuery, settings.refreshInterval, activeTagFilter])
🤖 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 `@apps/desktop/src/App.tsx` around lines 404 - 411, Guard the interval setup in
the useEffect containing loadClipboardHistory so a zero or empty
settings.refreshInterval cannot create a busy loop. Clamp the configured value
to a sane positive minimum before multiplying by 1000, while preserving the
existing cleanup and dependency behavior.

Comment thread apps/desktop/src/App.tsx
Comment on lines +466 to +475
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Don't handle if typing in search input
if (document.activeElement === searchInputRef.current) {
if (e.key === 'Escape') {
searchInputRef.current?.blur()
setSearchQuery('')
}
return
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Global keyboard handler hijacks typing in every input except the search box.

The guard only bypasses when document.activeElement === searchInputRef.current. When the user types in the tag-name input, master/unlock password fields, sync server/token inputs, or the numeric settings inputs, this global handler still runs and calls e.preventDefault() for j/k/p/f/d/Enter/arrows — so those characters never reach the field and instead fire list actions (pin/favorite/delete/copy) on the hovered item.

Gate the entire handler on whether focus is in any editable element.

🔧 Proposed fix
     const handleKeyDown = (e: KeyboardEvent) => {
+      // Don't handle shortcuts while typing in any editable field
+      const el = document.activeElement as HTMLElement | null
+      const isEditable =
+        el instanceof HTMLInputElement ||
+        el instanceof HTMLTextAreaElement ||
+        (el?.isContentEditable ?? false)
       // Don't handle if typing in search input
       if (document.activeElement === searchInputRef.current) {
         if (e.key === 'Escape') {
           searchInputRef.current?.blur()
           setSearchQuery('')
         }
         return
       }
+      if (isEditable) return
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Don't handle if typing in search input
if (document.activeElement === searchInputRef.current) {
if (e.key === 'Escape') {
searchInputRef.current?.blur()
setSearchQuery('')
}
return
}
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Don't handle shortcuts while typing in any editable field
const el = document.activeElement as HTMLElement | null
const isEditable =
el instanceof HTMLInputElement ||
el instanceof HTMLTextAreaElement ||
(el?.isContentEditable ?? false)
// Don't handle if typing in search input
if (document.activeElement === searchInputRef.current) {
if (e.key === 'Escape') {
searchInputRef.current?.blur()
setSearchQuery('')
}
return
}
if (isEditable) return
🤖 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 `@apps/desktop/src/App.tsx` around lines 466 - 475, Update the handleKeyDown
global keyboard handler in the App useEffect to return immediately whenever
document.activeElement is any editable control, including inputs, textareas,
selects, contenteditable elements, and the existing search input Escape
behavior. Preserve search-field blur/query clearing for Escape while preventing
shortcut handling and preventDefault from affecting all other editable fields.

Comment on lines +47 to 58
fn router(state: AppState) -> Router {
Router::new()
.route("/api/v1/status", get(handlers::status))
.route(
"/api/v1/clipboard",
get(handlers::get_clipboard).post(handlers::set_clipboard),
)
.route("/api/v1/search", post(handlers::search))
.route("/api/v1/status", get(status))
.route("/api/v1/clipboard", get(get_clipboard).post(set_clipboard))
.route("/api/v1/search", post(search))
.route("/api/v1/history", get(list_history))
.route("/api/v1/item/:id", get(get_item).delete(delete_item))
.route("/api/v1/sync/push", post(sync_push))
.route("/api/v1/sync/pull", get(sync_pull))
.with_state(state)
.layer(CorsLayer::permissive())
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🔴 Critical | 🏗️ Heavy lift

No authentication and permissive CORS on all API endpoints.

The router exposes every endpoint without any auth middleware, and CorsLayer::permissive() allows any origin. The sync client (crates/clipboard-sync/src/sync.rs) sends bearer_auth(token) when configured, but the server never validates it. The usage example in main.rs shows --addr 0.0.0.0:8080, meaning the API can be network-exposed. Anyone who can reach the server can read clipboard history (potentially containing passwords or PII), inject content, delete items, and push/pull sync data.

At minimum, add a bearer-token extraction layer and restrict CORS to known origins.

🔒 Suggested auth middleware using Axum middleware + tower-http
 use axum::{
     extract::{Path, Query, State},
     routing::{get, post},
     Json, Router,
 };
+use axum::http::{header::AUTHORIZATION, HeaderMap, StatusCode};
+use axum::middleware::{self, Next};
+use axum::response::Response;

+async fn require_auth(
+    headers: HeaderMap,
+    State(state): State<AppState>,
+    next: Next,
+) -> Result<Response, StatusCode> {
+    let expected = std::env::var("OPENPASTE_API_TOKEN").unwrap_or_default();
+    if expected.is_empty() {
+        return Ok(next.run(state).await); // No token configured = open mode
+    }
+    let token = headers
+        .get(AUTHORIZATION)
+        .and_then(|v| v.to_str().ok())
+        .and_then(|v| v.strip_prefix("Bearer "))
+        .unwrap_or("");
+    if token == expected {
+        Ok(next.run(state).await)
+    } else {
+        Err(StatusCode::UNAUTHORIZED)
+    }
+}

 fn router(state: AppState) -> Router {
     Router::new()
         .route("/api/v1/status", get(status))
         .route("/api/v1/clipboard", get(get_clipboard).post(set_clipboard))
         .route("/api/v1/search", post(search))
         .route("/api/v1/history", get(list_history))
         .route("/api/v1/item/:id", get(get_item).delete(delete_item))
         .route("/api/v1/sync/push", post(sync_push))
         .route("/api/v1/sync/pull", get(sync_pull))
         .with_state(state)
-        .layer(CorsLayer::permissive())
+        .layer(middleware::from_fn_with_state(state.clone(), require_auth))
+        .layer(CorsLayer::new()
+            .allow_origin([axum::http::HeaderValue::from_static("http://localhost:1420")])
+            .allow_methods(Any)
+            .allow_headers(Any))
 }
🤖 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 `@crates/clipboard-api/src/api.rs` around lines 47 - 58, Add bearer-token
authentication middleware to the API router in router, validating the token sent
by sync.rs against configured server credentials and rejecting missing or
invalid tokens before reaching clipboard, history, item, and sync handlers.
Replace CorsLayer::permissive() with an allowlist of explicitly configured
trusted origins, preserving only necessary methods and headers; ensure
configuration is threaded through AppState or router setup and update the
0.0.0.0 example as appropriate.

Comment on lines +255 to +257
match state.db.insert_item(&item).await {
Ok(_) | Err(_) => accepted.push(wire.hash), // duplicates are also "accepted"
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win

sync_push silently swallows all database errors as "accepted".

Ok(_) | Err(_) treats every insert outcome — including disk full, corruption, or connection failures — as accepted. The comment says "duplicates are also accepted", but the code goes far beyond that. The client will believe items were synced when they may not have been inserted, causing silent data loss.

The set_clipboard handler (line 144) correctly distinguishes UNIQUE violations from other errors. Apply the same pattern here.

🐛 Proposed fix to distinguish duplicates from real errors
-        match state.db.insert_item(&item).await {
-            Ok(_) | Err(_) => accepted.push(wire.hash), // duplicates are also "accepted"
-        }
+        match state.db.insert_item(&item).await {
+            Ok(_) => accepted.push(wire.hash),
+            Err(e) if e.to_string().contains("UNIQUE") => accepted.push(wire.hash),
+            Err(_) => rejected.push(wire.hash),
+        }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
match state.db.insert_item(&item).await {
Ok(_) | Err(_) => accepted.push(wire.hash), // duplicates are also "accepted"
}
match state.db.insert_item(&item).await {
Ok(_) => accepted.push(wire.hash),
Err(e) if e.to_string().contains("UNIQUE") => accepted.push(wire.hash),
Err(_) => rejected.push(wire.hash),
}
🤖 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 `@crates/clipboard-api/src/api.rs` around lines 255 - 257, Update sync_push’s
state.db.insert_item handling to accept only successful inserts and known
UNIQUE-constraint violations, following the error classification used by
set_clipboard. Propagate or return all other database errors instead of adding
their hashes to accepted, while preserving duplicate items as accepted.

Comment on lines +271 to +272
// Fetch recent items (server just returns last 500; client filters by since)
let items = match state.db.list_items(500, 0).await {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

sync_pull hardcodes a 500-item limit, causing silent data loss for larger histories.

list_items(500, 0) returns only the 500 most recent items. If more than 500 items were created after the client's since timestamp, older items are never returned and never synced. There is no pagination mechanism on this endpoint.

Consider filtering by since server-side in the database query, or adding pagination support to the sync pull endpoint.

🤖 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 `@crates/clipboard-api/src/api.rs` around lines 271 - 272, Update sync_pull to
avoid truncating synchronization at 500 records: preferably pass the since
timestamp into the database query used by list_items, or add pagination so all
items newer than since are retrieved and processed. Ensure the endpoint
continues fetching until the complete result set is synchronized.

Comment on lines +299 to +305
fn item_to_json(item: &clipboard_db::models::ClipboardItem) -> Value {
// Return text content as UTF-8 string; binary/encrypted as base64
let content_str = if item.encrypted {
"[encrypted]".to_string()
} else {
String::from_utf8_lossy(&item.content).to_string()
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Binary content (e.g., images) is corrupted by from_utf8_lossy in item_to_json.

Non-encrypted binary clipboard content (images, which this PR adds support for) will produce replacement characters and data loss when passed through String::from_utf8_lossy. The content_type field may indicate "image/png", but the content field will contain garbage. The sync endpoints handle this correctly by base64-encoding, but the regular API endpoints (get_clipboard, list_history, get_item, search) do not.

Check whether the content is valid UTF-8 and base64-encode if not, or gate on content_type.

💚 Proposed fix to base64-encode non-UTF-8 content
 fn item_to_json(item: &clipboard_db::models::ClipboardItem) -> Value {
-    // Return text content as UTF-8 string; binary/encrypted as base64
-    let content_str = if item.encrypted {
-        "[encrypted]".to_string()
-    } else {
-        String::from_utf8_lossy(&item.content).to_string()
-    };
+    use base64::{engine::general_purpose::STANDARD, Engine as _};
+
+    let content_str = if item.encrypted {
+        "[encrypted]".to_string()
+    } else {
+        match std::str::from_utf8(&item.content) {
+            Ok(s) => s.to_string(),
+            Err(_) => format!("data:base64,{}", STANDARD.encode(&item.content)),
+        }
+    };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn item_to_json(item: &clipboard_db::models::ClipboardItem) -> Value {
// Return text content as UTF-8 string; binary/encrypted as base64
let content_str = if item.encrypted {
"[encrypted]".to_string()
} else {
String::from_utf8_lossy(&item.content).to_string()
};
fn item_to_json(item: &clipboard_db::models::ClipboardItem) -> Value {
use base64::{engine::general_purpose::STANDARD, Engine as _};
let content_str = if item.encrypted {
"[encrypted]".to_string()
} else {
match std::str::from_utf8(&item.content) {
Ok(s) => s.to_string(),
Err(_) => format!("data:base64,{}", STANDARD.encode(&item.content)),
}
};
🤖 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 `@crates/clipboard-api/src/api.rs` around lines 299 - 305, Update item_to_json
to preserve binary clipboard data: keep encrypted content masked, return valid
UTF-8 content as text, and base64-encode content that is not valid UTF-8 (or is
identified as binary by content_type). Ensure all regular API endpoints using
item_to_json return lossless content consistent with the sync endpoints.

Comment on lines +76 to 131
/// On macOS, check whether the pasteboard currently advertises an image type,
/// without actually reading the image data. This avoids the ~50 ms arboard
/// penalty on every poll when the clipboard contains only text.
///
/// Returns `true` → image type is present, go ahead and call `get_image()`.
/// Returns `false` → skip image attempt entirely.
#[cfg(target_os = "macos")]
#[allow(unexpected_cfgs)]
fn pasteboard_has_image() -> bool {
// Use the Objective-C runtime to call
// [[NSPasteboard generalPasteboard] canReadObjectForClasses:...] or
// more simply check the types array for common image UTIs.
//
// We do this via a tiny inline Objective-C call using the `objc` crate
// that is already a transitive dependency.
use std::ffi::CStr;
unsafe {
use objc::runtime::{Class, Object};
use objc::{msg_send, sel, sel_impl};

let cls = match Class::get("NSPasteboard") {
Some(c) => c,
None => return false,
};
let pb: *mut Object = msg_send![cls, generalPasteboard];
if pb.is_null() {
return false;
}
}

#[async_trait]
impl ClipboardProvider for WindowsProvider {
async fn get_content(&self) -> Result<ClipboardItem, PlatformError> {
let mut ctx =
Clipboard::new().map_err(|e| PlatformError::AccessFailed(e.to_string()))?;

let text = ctx
.get_text()
.map_err(|e| PlatformError::AccessFailed(e.to_string()))?;

let content_bytes = text.into_bytes();
let content_type = ContentType::detect(&content_bytes);

Ok(ClipboardItem::new(content_type, content_bytes))
let types: *mut Object = msg_send![pb, types];
if types.is_null() {
return false;
}

async fn set_content(&self, item: &ClipboardItem) -> Result<(), PlatformError> {
if item.content_type == ContentType::Text {
let text = String::from_utf8_lossy(&item.content).to_string();
let mut ctx =
Clipboard::new().map_err(|e| PlatformError::AccessFailed(e.to_string()))?;

ctx.set_text(&text)
.map_err(|e| PlatformError::AccessFailed(e.to_string()))?;
let count: usize = msg_send![types, count];
for i in 0..count {
let type_str: *mut Object = msg_send![types, objectAtIndex: i];
if type_str.is_null() {
continue;
}
let utf8: *const std::os::raw::c_char = msg_send![type_str, UTF8String];
if utf8.is_null() {
continue;
}
let s = CStr::from_ptr(utf8).to_string_lossy();
// Common image UTIs advertised by macOS apps
if s.contains("image")
|| s == "public.png"
|| s == "public.jpeg"
|| s == "public.tiff"
|| s == "com.apple.pict"
{
return true;
}
// TODO: Handle other content types
Ok(())
}

async fn watch_changes(&self) -> Result<(), PlatformError> {
// TODO: Implement Windows clipboard watching using clipboard format listeners
Err(PlatformError::WatchFailed("Not implemented".to_string()))
}
false
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if objc is declared as a direct dependency and verify autoreleasepool availability
ast-grep outline crates/clipboard-platform/Cargo.toml --type toml 2>/dev/null || true
rg -n 'objc' crates/clipboard-platform/Cargo.toml
# Also check if objc::rc::autoreleasepool is used elsewhere in the codebase
rg -n 'autoreleasepool' --type rust crates/

Repository: anonfedora/openpaste

Length of output: 174


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== clipboard-platform Cargo.toml =="
sed -n '1,120p' crates/clipboard-platform/Cargo.toml

echo
echo "== objc dependency in lockfile =="
rg -n '^name = "objc"|^version = ".*"$' Cargo.lock -n -A2 -B1 | sed -n '1,40p'

echo
echo "== autoreleasepool usage in repo =="
rg -n 'objc::rc::autoreleasepool|autoreleasepool\(' crates/ -g '*.rs'

Repository: anonfedora/openpaste

Length of output: 2605


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate objc crate sources =="
python3 - <<'PY'
import glob, os
cands = glob.glob(os.path.expanduser('~/.cargo/registry/src/*/objc-*'))
print("\n".join(cands[:20]))
PY

echo
echo "== search for autoreleasepool in objc sources =="
python3 - <<'PY'
import glob, os, subprocess, shlex
for root in glob.glob(os.path.expanduser('~/.cargo/registry/src/*/objc-*')):
    print(f"-- {root} --")
    subprocess.run(["bash","-lc",f"rg -n 'autoreleasepool|pub mod rc|mod rc' {shlex.quote(root)}"], check=False)
PY

echo
echo "== inspect relevant objc source files if present =="
python3 - <<'PY'
import glob, os, subprocess, shlex, pathlib
roots = glob.glob(os.path.expanduser('~/.cargo/registry/src/*/objc-*'))
for root in roots[:3]:
    for path in pathlib.Path(root).rglob("*.rs"):
        if path.name in {"lib.rs", "rc.rs"}:
            print(f"\n-- {path} --")
            subprocess.run(["bash","-lc",f"sed -n '1,260p' {shlex.quote(str(path))}"], check=False)
PY

Repository: anonfedora/openpaste

Length of output: 294


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== crates/clipboard-platform/src/provider.rs =="
sed -n '1,220p' crates/clipboard-platform/src/provider.rs

echo
echo "== search for pasteboard_has_image callers =="
rg -n 'pasteboard_has_image|get_image\(' crates/clipboard-platform/src -g '*.rs'

echo
echo "== search for any objc / cocoa autorelease handling =="
rg -n 'autoreleasepool|NSAutoreleasePool|NSApplication|runloop|main thread' crates/ -g '*.rs'

Repository: anonfedora/openpaste

Length of output: 8602


🌐 Web query:

docs.rs objc 0.2 autoreleasepool objc::rc::autoreleasepool

💡 Result:

In the objc crate (version 0.2), objc::rc::autoreleasepool is a function used to execute code within the context of a new Objective-C autorelease pool [1][2]. It provides a Rust-safe wrapper for the @autoreleasepool blocks found in Objective-C and Swift [1][3]. The function signature is defined as [1]: pub fn autoreleasepool<T, F: FnOnce -> T>(f: F) -> T When called, autoreleasepool creates a new autorelease pool, executes the provided closure f, and drains the pool immediately after the closure completes [1][2]. This is essential for managing the memory of Objective-C objects that have been marked for autorelease—preventing memory accumulation by ensuring they are released at the end of the specified scope rather than waiting for the thread's main pool to drain [4][5]. Key points regarding its usage: - ARC-like Semantics: The rc module provides utilities like StrongPtr and WeakPtr to manage reference counting [4][2]. When you call .autorelease() on a StrongPtr inside an autoreleasepool block, the ownership of that object is effectively transferred to the pool, and it will be released when the pool drains [4][5]. - Relationship to objc2: The objc crate has been largely succeeded by the objc2 crate [6][7]. In objc2, the autoreleasepool function has been updated to accept the pool as a parameter, allowing for safer management of lifetimes for objects tied to the pool [8][7]. If you are working on modern projects, it is recommended to use objc2 for better safety guarantees and updated APIs [7].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== find macOS entrypoints and provider construction =="
rg -n 'PlatformProvider::Macos|PlatformProvider|watch_changes|get_content|read_clipboard_item|tokio::main|main\(' crates/ -g '*.rs'

echo
echo "== inspect crate roots / binaries =="
git ls-files | rg '(^|/)(main|lib)\.rs$|Cargo\.toml$'

echo
echo "== search for any explicit autorelease pool setup in macOS-related code =="
rg -n 'autoreleasepool|NSAutoreleasePool|cocoa::app|NSApplication|NSApp' crates/ -g '*.rs'

Repository: anonfedora/openpaste

Length of output: 3174


Wrap the macOS pasteboard probe in an autorelease pool crates/clipboard-platform/src/provider.rs:84objc::rc::autoreleasepool is available in the declared objc = "0.2" dependency, and this path runs on every poll; drain the Cocoa temporaries here to avoid unbounded growth in the daemon.

🤖 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 `@crates/clipboard-platform/src/provider.rs` around lines 76 - 131, Wrap the
entire Objective-C probe in pasteboard_has_image with objc::rc::autoreleasepool,
returning the existing boolean result from its closure. Keep all Cocoa object
access inside the pool so temporary objects are drained on every poll, while
preserving the current null checks and image-type detection logic.

Comment on lines 52 to 57
pub fn new() -> Self {
Self {}
Self {
engine: Engine::default(),
plugins: Arc::new(Mutex::new(HashMap::new())),
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🌐 Web query:

wasmtime consume_fuel epoch_interruption set deadline example

💡 Result:

In Wasmtime, fuel and epoch interruption are two distinct mechanisms used to interrupt or limit the execution of WebAssembly code [1][2]. They are often configured separately, though they can be used together [2]. Fuel Consumption Fuel is a deterministic mechanism where every Wasm operation consumes a specific amount of fuel [1][3]. Once a store runs out of fuel, execution traps [1][3]. 1. Configuration: Enable fuel in your engine config using config.consume_fuel(true) [2]. 2. Initialization: Set an initial fuel amount in your store using store.set_fuel(amount)? [2]. 3. Usage: You can check remaining fuel or replenish it during execution using store.get_fuel and store.add_fuel(amount)? [2]. Epoch Interruption Epoch interruption is a cooperative, timer-based mechanism that is generally more performant but coarser-grained than fuel [1][2]. The host increments an epoch counter, and Wasmtime checks this at safe points [2]. 1. Configuration: Enable epoch interruption in your config using config.epoch_interruption(true) [4][2]. 2. Deadline: Use store.set_epoch_deadline(value) to set when the next trap should occur relative to the engine's current epoch [4][2]. 3. Trigger: Call engine.increment_epoch from a host thread to advance the epoch, which will trigger a trap in the guest if the deadline has been reached [4][2]. Example Summary To implement these, you typically configure the engine, create a store, and set the initial limits [4][2]: let mut config = Config::new; config.consume_fuel(true); config.epoch_interruption(true); let engine = Engine::new(&config)?; let mut store = Store::new(&engine, ); store.set_fuel(10_000)?; // Fuel-based limit store.set_epoch_deadline(1)?; // Epoch-based limit For asynchronous environments, you can also use store.fuel_async_yield_interval or store.epoch_deadline_async_yield_and_update to yield control back to the executor instead of trapping [1][5].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== file outline ==\n'
ast-grep outline crates/clipboard-plugin/src/plugin.rs --view expanded || true

printf '\n== relevant file section ==\n'
nl -ba crates/clipboard-plugin/src/plugin.rs | sed -n '1,260p'

printf '\n== search for engine construction and execution path ==\n'
rg -n "Engine::default|Config::new|consume_fuel|epoch_interruption|set_epoch_deadline|set_fuel|process_item|clipboard-plugin" crates -g '!**/target/**' || true

Repository: anonfedora/openpaste

Length of output: 947


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== plugin.rs (lines 1-260) ==\n'
sed -n '1,260p' crates/clipboard-plugin/src/plugin.rs

printf '\n== wasmtime config/search in clipboard-plugin ==\n'
rg -n "Config::new|consume_fuel|epoch_interruption|set_epoch_deadline|set_fuel|Engine::default|Engine::new|Store::new|Linker::new|call_async|call" crates/clipboard-plugin -g '!**/target/**' || true

Repository: anonfedora/openpaste

Length of output: 9529


Bound untrusted plugin execution Engine::default() leaves process_fn.call(...) unbounded, so a plugin with an infinite process_item loop can block the caller thread indefinitely. Configure fuel or epoch interruption and apply a per-call limit in run_plugin.

🤖 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 `@crates/clipboard-plugin/src/plugin.rs` around lines 52 - 57, Bound untrusted
plugin execution by configuring interruption support in the constructor `new`
and enforcing a per-call limit in `run_plugin`. Enable Wasmtime fuel or epoch
interruption on `Engine`, then reset and apply the configured budget before each
`process_fn.call(...)`, handling exhaustion as a plugin execution error.

Comment on lines +219 to +230
let result_len = process_fn
.call(&mut store, (0, content_bytes.len() as i32))
.map_err(|e| PluginError::ExecutionFailed(format!("call: {}", e)))?;

if result_len <= 0 {
return Ok(None); // no transformation
}

// Read back the result from memory offset 0
let result_bytes: Vec<u8> = memory.data(&store)[..result_len as usize].to_vec();
let result = String::from_utf8(result_bytes)
.map_err(|e| PluginError::ExecutionFailed(format!("utf8: {}", e)))?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Untrusted result_len can panic the host via out-of-bounds slice.

result_len is returned by the guest WASM and only checked for <= 0. If a plugin returns a length larger than its linear memory, memory.data(&store)[..result_len as usize] panics and takes down the caller. Bound it against the memory length before slicing.

🛡️ Proposed fix
         if result_len <= 0 {
             return Ok(None); // no transformation
         }
 
-        // Read back the result from memory offset 0
-        let result_bytes: Vec<u8> = memory.data(&store)[..result_len as usize].to_vec();
+        // Read back the result from memory offset 0
+        let data = memory.data(&store);
+        let end = result_len as usize;
+        if end > data.len() {
+            return Err(PluginError::ExecutionFailed(
+                "Plugin returned length exceeding guest memory".to_string(),
+            ));
+        }
+        let result_bytes: Vec<u8> = data[..end].to_vec();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let result_len = process_fn
.call(&mut store, (0, content_bytes.len() as i32))
.map_err(|e| PluginError::ExecutionFailed(format!("call: {}", e)))?;
if result_len <= 0 {
return Ok(None); // no transformation
}
// Read back the result from memory offset 0
let result_bytes: Vec<u8> = memory.data(&store)[..result_len as usize].to_vec();
let result = String::from_utf8(result_bytes)
.map_err(|e| PluginError::ExecutionFailed(format!("utf8: {}", e)))?;
let result_len = process_fn
.call(&mut store, (0, content_bytes.len() as i32))
.map_err(|e| PluginError::ExecutionFailed(format!("call: {}", e)))?;
if result_len <= 0 {
return Ok(None); // no transformation
}
// Read back the result from memory offset 0
let data = memory.data(&store);
let end = result_len as usize;
if end > data.len() {
return Err(PluginError::ExecutionFailed(
"Plugin returned length exceeding guest memory".to_string(),
));
}
let result_bytes: Vec<u8> = data[..end].to_vec();
let result = String::from_utf8(result_bytes)
.map_err(|e| PluginError::ExecutionFailed(format!("utf8: {}", e)))?;
🤖 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 `@crates/clipboard-plugin/src/plugin.rs` around lines 219 - 230, Validate the
guest-provided result_len against the available linear memory before slicing in
the transformation flow around process_fn.call. After handling nonpositive
values, compare result_len as a safely converted usize with
memory.data(&store).len(); return an appropriate PluginError::ExecutionFailed
for oversized lengths, and only then create result_bytes from the bounded slice.

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