# Technical Review Report — cowork-z

### Branch: `main` (HEAD 4b26363) | Generated: June 11, 2026

## Executive Summary

This report covers a full-repository technical review of cowork-z, a cross-platform Tauri 2.x desktop application that provides a sandboxed environment for autonomous AI agents. The application spans three runtimes: a React 19 / TypeScript frontend, a Rust backend, and a Node.js sidecar that drives an `opencode serve` HTTP server. The review focused on security (secrets handling, command/path injection, webview hardening), robustness (process lifecycle, error handling, concurrency), and code quality (IPC contract consistency, dead code, test coverage).

### Repository statistics

Metric | Value
--- | ---
Total source files (`.ts`, `.tsx`, `.rs`, `.js`, `.css`, `.html`; excludes `node_modules`, `dist`, `target`, `binaries`, reference `apps/desktop`) | 260
Total lines of code (same scope) | 44,793
↳ Frontend (`src/`, `.ts`/`.tsx`) | 180 files / 26,685 LOC
↳ Rust backend (`src-tauri/src/`) | 45 files / 10,970 LOC
↳ Node sidecar (`src-tauri/sidecar-opencode/src/`) | 9 files / 3,208 LOC

### Findings by severity

Severity | Count
--- | ---
CRITICAL | 0
HIGH | 7
MEDIUM | 19
LOW | 9
Total | 35

The most significant theme is webview hardening: the Content-Security-Policy is disabled and the asset protocol is scoped to the entire filesystem, so any rendering/XSS flaw in the agent-content pipeline (markdown, HTML previews, file previews) escalates into full access to every IPC command — including unrestricted file read/write commands. A second theme is secret handling: the per-launch OpenCode server password (which authenticates a command-executing API) is written to a plaintext log and forwarded to stdout, and Git PATs are persisted to `.git/config`. A third theme is IPC contract drift between the three runtimes, which produces real correctness bugs (e.g. aborted tasks persisted as `failed`) and dropped configuration fields.

## 1. Content-Security-Policy is fully disabled

HIGH

Summary: The Tauri webview ships with `csp: null`, so no Content-Security-Policy is injected.

Details: This application renders AI-agent-generated markdown, code, and arbitrary workspace file previews. With no CSP, a single rendering or injection flaw anywhere in that pipeline gives injected script full access to every Tauri IPC command exposed to the window (tasks, settings, keychain-backed providers, and the unrestricted file commands in Finding 3). CSP is the primary defense-in-depth layer for a content-rendering desktop app, and it is absent.

Occurrences: src-tauri/tauri.conf.json:22-23

"security": { "csp": null,
Recommendation: Define a restrictive policy such as `default-src 'self'; img-src 'self' asset: http://asset.localhost; connect-src ipc: http://ipc.localhost` and let Tauri append its own script/style nonces. Dev mode needs `http://localhost:1420`; verify with `pnpm tauri dev`.

## 2. Asset protocol scoped to the entire filesystem

HIGH

Summary: The `asset:` protocol scope is `["**"]`, granting the webview read access to any file the user can read.

Details: A scope of `**` lets any code running in the webview load any local file (SSH keys, browser profiles, documents) through the `asset:` protocol. Combined with the disabled CSP (Finding 1), a single XSS in markdown or file-preview rendering becomes arbitrary local-file exfiltration.

Occurrences: src-tauri/tauri.conf.json:24-29

"assetProtocol": { "enable": true, "scope": [ "**" ] }
Recommendation: Scope the asset protocol to what media previews actually require — the active workspace root(s) and the app data dir — or route previews through an existing Rust command that validates paths against the workspace permission model.

## 3. Unrestricted filesystem Tauri commands (write / read / trash)

HIGH

Summary: Several Tauri commands reachable from the renderer perform filesystem operations on any absolute path with no validation or workspace scoping.

Details: `write_text_file` writes attacker-controlled content to any path. `read_file_content` / `read_binary_file` read any file (only size-limited), and `trash_file` trashes any file. None validate the path against the workspace permission model. With the CSP disabled (Finding 1), an XSS in the main webview turns these into an arbitrary file-write/read/delete primitive (e.g. overwriting shell startup files).

Occurrences: src-tauri/src/commands/logging.rs:13-16, src-tauri/src/commands/files.rs:8-73

#[tauri::command] pub async fn write_text_file(path: String, contents: String) -> Result<(), String> { std::fs::write(&path, &contents).map_err(|e| format!("Failed to write file: {}", e)) }
Recommendation: Validate every path against the active workspace + granted folder permissions before any read/write/trash. Reject absolute paths outside the allowed roots and canonicalize to defeat `..` traversal.

## 4. OpenCode server password leaked to log file and IPC stdout

HIGH

Summary: The per-launch random server password is logged at `debug` level, and the logger has no level gating — it writes to a plaintext file and mirrors to stdout/IPC.

Details: The OpenCode server's basic-auth password authenticates an API that can execute shell commands as the user. It is written verbatim to a log file under `~/.local/share/opencode/log/` and forwarded through the IPC emitter to stdout, which the Rust parent relays to the frontend debug panel. Anyone able to read the log directory (other local processes, backup/sync tools) gains full authenticated control of the agent runtime, defeating the entire per-launch-password design.

Occurrences: src-tauri/sidecar-opencode/src/process-manager.ts:482-484; amplified by src/logger.ts:53-69

// Enable HTTP basic auth on the OpenCode server env.OPENCODE_SERVER_PASSWORD = this.password; logger.debug(`OPENCODE_SERVER_PASSWORD=${this.password}`);
Recommendation: Delete this log line (or log only `password.length`). Add log-level gating to `Logger` and a redaction pass over known secret keys before anything is written to file or IPC.

## 5. HTML preview executes untrusted agent JS with a sandbox escape

HIGH

Summary: Agent-written `.html` files are rendered in an iframe whose `sandbox` includes `allow-scripts` together with `allow-popups-to-escape-sandbox`.

Details: Any HTML file in the workspace (typically authored by the AI agent) runs with `allow-scripts`, executing arbitrary JS inside the app webview; `allow-popups-to-escape-sandbox` lets that script `window.open()` a page that escapes the sandbox entirely. Additionally, `baseHref` is interpolated into the `<base href="...">` attribute with no quote-escaping, so a directory name containing a double-quote breaks out of the attribute.

Occurrences: src/components/file-preview/HtmlPreview.tsx:9-22, src/components/file-preview/FilePreviewPanel.tsx:150-154

<iframe className="h-full w-full border-none" referrerPolicy="no-referrer" sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox allow-forms" srcDoc={finalContent}
Recommendation: Remove `allow-popups-to-escape-sandbox` (and ideally `allow-popups`); consider dropping `allow-scripts` and offering an explicit "open in browser" action for interactive previews. HTML-encode `baseHref` before interpolation.

## 6. Orphaned `opencode serve` process on shutdown

HIGH

Summary: The SIGKILL fallback timer in `stopServer()` can never fire before `process.exit(0)`, and the Rust side never calls its (dead-code) `stop()` on app exit.

Details: `stopServer()` resolves immediately after `disposeGlobal()` and merely schedules a 5-second SIGKILL; both shutdown paths call `process.exit(0)` right after, so the timer never runs. The child is spawned with `detached: false`, which on Unix does not kill it on parent exit. On the Rust side, `SidecarManager::stop()` is marked `#[allow(dead_code)]` and is never invoked, so the graceful `shutdown` stdin message is never sent. If `disposeGlobal()` fails, `opencode serve` is orphaned with API keys in its environment and a live listening port.

Occurrences: src-tauri/sidecar-opencode/src/process-manager.ts:596-614, src-tauri/src/sidecar.rs:581-595

// Force kill if still running after 5 seconds setTimeout(() => { if (this.process) { logger.warn('Force killing OpenCode process'); this.process.kill('SIGKILL'); } }, 5000); // ...but process.exit(0) runs first
Recommendation: Make `stopServer()` await termination (SIGTERM, race a 5s timeout, then SIGKILL, resolving on the child's `exit` event). Wire a real `stop()` call into the Rust app's exit/window-close handler so the sidecar shuts down deterministically.

## 7. `task_complete` status disagrees across all three IPC layers

HIGH

Summary: The completion-status vocabulary differs between the sidecar (source of truth), the Rust persistence layer, and the frontend, producing incorrect persisted history.

Details: The sidecar emits `'aborted'` on user abort and never emits `'interrupted'`. Rust maps `"interrupted" => "interrupted"` (dead arm) and lumps `aborted`/`cancelled` into `"failed"`, while the frontend maps `aborted`/`cancelled` to `"interrupted"`. The result: a user-aborted task shows as "interrupted" live, but is persisted by Rust as `failed` — so after an app restart, history flips from "interrupted" to "failed".

Occurrences: src-tauri/src/sidecar.rs:530-534, src-tauri/sidecar-opencode/src/session-manager.ts:446-450, src/lib/tauri-api.ts:954-961

let mapped_status = match sidecar_status { "success" => "completed", "interrupted" => "interrupted", // never emitted by sidecar _ => "failed", // swallows aborted/cancelled };
Recommendation: Define one canonical status union in `types.ts`, add an `aborted`/`cancelled => "interrupted"` arm to the Rust mapping, and align `src/shared/types/task.ts`. Add a contract test that deserializes `types.ts` fixtures through the Rust serde types.

## 8. Git PAT persisted to `.git/config` in plaintext

MEDIUM

Summary: `pull_repo` injects the personal access token into the remote URL and persists it via `git remote set-url`, leaving the token in `<cache>/.git/config`.

Details: While the token originates from the OS keychain, writing it into the on-disk Git config defeats that protection: the secret now sits in cleartext on disk, readable by any local process and likely to survive in backups. `clone_repo` avoids persistence (token only in the transient argv), but `pull_repo` rewrites the remote URL permanently.

Occurrences: src-tauri/src/git_ops.rs:44-49, src-tauri/src/git_ops.rs:77-93

if let Some(token) = auth_token { let current_url = get_remote_url(repo_dir)?; let new_url = inject_token(&current_url, Some(token)); set_remote_url(repo_dir, &new_url)?; // token written to .git/config }
Recommendation: Pass credentials transiently per invocation (e.g. `git -c http.extraHeader=...` or a credential helper / `askpass`) instead of persisting them into the remote URL. If persistence is unavoidable, scrub the token from the URL after the operation.

## 9. DB mutex poisoning causes cascading panics

MEDIUM

Summary: The shared SQLite connection is guarded by a `std::sync::Mutex` accessed with `.lock().unwrap()` in ~20 call sites; one panic while the lock is held poisons it permanently.

Details: If any thread panics while holding the DB lock (e.g. a serialization edge case during a query), the mutex becomes poisoned and every subsequent `conn.lock().unwrap()` panics, taking down all database access for the rest of the session. The background skill-sync thread and automation scheduler both lock the same connection, widening the blast radius.

Occurrences: src-tauri/src/lib.rs:105,153,175, src-tauri/src/automation_scheduler.rs (15 sites), src-tauri/src/commands/automations.rs (11 sites)

let repos = { let conn = db_state.conn.lock().unwrap(); // poisoning here cascades crate::db::skill_repos::list_skill_repos(&conn) };
Recommendation: Recover from poisoning with `.lock().unwrap_or_else(|e| e.into_inner())` (the connection state is still usable), or centralize DB access behind a helper that handles poisoning and surfaces a `Result` instead of panicking.

## 10. MCP secrets, HTTP bodies, and SSE events logged in plaintext

MEDIUM

Summary: Full session config (including MCP `environment`/`headers` secrets), every HTTP response body, and every SSE event are written to unrotated plaintext logs and forwarded over IPC.

Details: `McpConfig` carries `environment` and `headers` maps that typically contain API tokens; the whole config is logged at `info` level. Additionally every HTTP response body and SSE event (the full conversation content, which can include user file contents) is persisted to a new log file per session with no rotation or cleanup.

Occurrences: src-tauri/sidecar-opencode/src/session-manager.ts:321-323, src-tauri/sidecar-opencode/src/opencode-client.ts:57-58

const config = buildSessionConfig({ modelId, folderPermissions, mcpServers }); await this.client.updateConfig(config, workingDirectory); logger.info('Config updated for session', config); // logs MCP secrets
Recommendation: Redact `environment`/`headers` values before logging configs; gate full body/event logging behind an explicit debug flag; add log rotation or a retention policy.

## 11. Server password embedded in the LLM system prompt

MEDIUM

Summary: The OpenCode server basic-auth credential is inlined into the system prompt and into `curl` examples on every message.

Details: Though the agent legitimately needs to call the server API, transmitting the credential to a third-party model provider on every request — and instructing the agent to put it on `curl` command lines — means it reappears in bash tool-call transcripts (themselves logged, see Finding 10) and in `ps` output while curl runs.

Occurrences: src-tauri/sidecar-opencode/src/config-builder.ts:97-103

<server-access> The OpenCode server is running at http://localhost:${serverPort} Authenticate with: -u opencode:${serverPassword} ... </server-access>
Recommendation: Mint a separate scoped token for agent self-access, or have the agent read the credential at runtime from `$OPENCODE_SERVER_PASSWORD` rather than inlining it into the prompt and command lines.

## 12. TOCTOU race in ephemeral port selection

MEDIUM

Summary: A probe socket binds port 0, reads the assigned port, then closes; only later is `opencode serve --port <port>` spawned, leaving a window for another process to grab the port.

Details: Between closing the probe socket and the server binding, config writes and PATH resolution run. If another process takes the port in that window, the symptom is a confusing 15-second `waitForServer` timeout ("server failed to start") with no retry on a fresh port.

Occurrences: src-tauri/sidecar-opencode/src/process-manager.ts:280-298, :470-502

server.listen(0, '127.0.0.1', () => { const addr = server.address(); const port = addr.port; server.close(() => { resolve(port); }); // port released, then reused later });
Recommendation: Detect bind failure (child stderr / early exit) and retry with a fresh port (bounded attempts). Better, let `opencode serve` bind port 0 itself and parse the actual port from its startup output.

## 13. SSE reconnect timer resurrects after disconnect; no backoff

MEDIUM

Summary: The reconnect `setTimeout` handle is discarded and never cleared, and its callback calls `connect()`, which unconditionally re-enables reconnection.

Details: If `disconnect()` runs while a timer is pending, the timer still fires and resurrects the stream, overriding the `shouldReconnect = false` intent. When the server is permanently dead, the stream retries every 5s forever with no backoff or cap, spamming warn-level logs over IPC.

Occurrences: src-tauri/sidecar-opencode/src/event-stream.ts:103-118, :121-129

if (this.eventSource?.readyState === EventSource.CLOSED && this.shouldReconnect) { setTimeout(() => this.connect(), this.reconnectInterval); // handle discarded }
Recommendation: Store the timer handle, clear it in `disconnect()`, re-check `shouldReconnect` inside the callback, and add exponential backoff with a maximum interval.

## 14. `apiKeys` silently ignored after first initialization

MEDIUM

Summary: API keys are applied only as env vars when the server process spawns; once initialized, subsequent `start_task`/`resume_session` payloads' keys are dropped without warning.

Details: After `sessionManager` exists, `initialize()` early-returns, so a key added or rotated mid-session never reaches the server environment (model changes, by contrast, are handled). The result is stale/missing credentials and a confusing provider error unless the whole sidecar is restarted.

Occurrences: src-tauri/sidecar-opencode/src/index.ts:56-64, src/process-manager.ts:487-500

if (sessionManager) { // reconnect SSE if directory changed... then: return; // apiKeys / mcpServers on this payload are discarded }
Recommendation: Detect changed `apiKeys` in `initialize()` and either restart the server process or push keys via OpenCode's auth API; at minimum log a warning that new keys were ignored.

## 15. Unused shell permissions exposed to webview windows

MEDIUM

Summary: Both capability files grant shell permissions (including unscoped `shell:allow-execute`) that are never used by frontend code.

Details: There is zero use of `@tauri-apps/plugin-shell` in `src/` — the sidecar is spawned Rust-side and skill-repo git ops run in Rust. These grants are pure unused IPC attack surface, magnified by the disabled CSP (Finding 1). `opener:allow-open-path` with `{"path": "**"}` is similarly broad.

Occurrences: src-tauri/capabilities/default.json:13-16, src-tauri/capabilities/skills.json:6

"shell:allow-spawn", "shell:allow-stdin-write", "shell:allow-kill", "shell:allow-open",
Recommendation: Remove the unused `shell:allow-*` grants from both capability files and narrow `opener:allow-open-path` from `**` to the directories actually opened.

## 16. CI silently discards lint failures

MEDIUM

Summary: The CI step backgrounds the lint command with a single `&`, so only the test exit code gates the build.

Details: `pnpm ultracite:check & pnpm test --run` backgrounds the lint/format check; the step's exit code is that of `pnpm test --run` only. Lint and formatting violations can therefore never fail CI despite the step being named "lint and tests".

Occurrences: .github/workflows/test.yml:49-50

- name: Run frontend lint and tests run: pnpm ultracite:check & pnpm test --run
Recommendation: Change `&` to `&&`, or split into two independent steps so each failure is visible and blocking.

## 17. Azure Foundry key dropped by sidecar; inconsistent provider id

MEDIUM

Summary: The Azure Foundry API key is read and serialized by Rust but has no field in the sidecar's `ApiKeys` interface, and the provider id is spelled inconsistently across modules.

Details: Rust reads `get_api_key("azureFoundry")` and sends it as `azureFoundry`, but the sidecar's `ApiKeys` type and env-var mapping have no Azure field, so the key is silently discarded. Separately, the keychain status list in `secure_storage.rs` enumerates `"azure-foundry"` (kebab-case) while the key is actually stored under `"azureFoundry"` (camelCase), so `get_all_api_key_status` reports it as absent even when configured.

Occurrences: src-tauri/src/sidecar.rs:654-656, src-tauri/src/secure_storage.rs:28, src-tauri/sidecar-opencode/src/types.ts:229-239

if let Ok(Some(key)) = secure_storage::get_api_key("azureFoundry") { keys.azure_foundry = Some(key); // sidecar ApiKeys has no azureFoundry field }
Recommendation: Standardize on one provider id, add `azureFoundry` to the sidecar `ApiKeys` interface and env-var mapping (or remove the dead Rust path if Azure auth is delivered via provider config instead).

## 18. `update_mcp_config` never sends `workingDirectory`

MEDIUM

Summary: The sidecar expects an optional `workingDirectory` on the MCP-config update, but the Rust payload struct omits the field entirely.

Details: Per the project's own architecture, OpenCode server requests must carry `?directory=<workspace>` to route state to the correct workspace instance. The `PATCH /config` issued through this path is always directory-less, so MCP config updates may not apply to the active workspace's server.

Occurrences: src-tauri/src/sidecar.rs:182-186, src-tauri/sidecar-opencode/src/types.ts:314-317

#[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct UpdateMcpConfigPayload { pub mcp_servers: serde_json::Value, // missing working_directory }
Recommendation: Add `working_directory: Option<String>` to the Rust struct and populate it from the active workspace in `commands/settings.rs`.

## 19. `respondToPermission` stale-read loop drops folder grants

MEDIUM

Summary: The handler destructures `folderPermissions` once, then spreads that original array on each loop iteration, so only the last pattern's grant survives.

Details: For a multi-pattern permission request, every iteration calls `set({ folderPermissions: [...folderPermissions, grant] })` against the stale snapshot, so iteration 2 discards the folder added in iteration 1. It is also stale against any concurrent `addFolderPermission`.

Occurrences: src/stores/taskStore.ts:686, :726-744

if (!folderPermissions.some((fp) => fp.folderPath === targetFolder)) { const newPerms = [...folderPermissions, /* grant */]; // stale base each iter set({ folderPermissions: newPerms }); }
Recommendation: Use the functional updater `set((state) => ({ folderPermissions: [...state.folderPermissions, grant] }))`, or accumulate all grants and call `set` once after the loop.

## 20. Duplicate `onTaskUpdate` listeners double-process completion

MEDIUM

Summary: Both the Sidebar (always mounted) and the Execution page register `onTaskUpdate` listeners, and the dedup cache is cleared before the second listener sees a complete/error event.

Details: Duplicate events are normally absorbed by a module-level dedup map, but for `complete`/`error` the map entry is deleted immediately after the first invocation. The second listener then fully re-processes the event: `api.completeTask` writes to the DB twice and state is set twice.

Occurrences: src/components/layout/Sidebar.tsx:158-160, src/pages/Execution.tsx:163-178, src/stores/taskStore.ts:846-854

const unsubscribeTaskUpdate = api.onTaskUpdate((event) => { addTaskUpdate(event); // also registered in Execution.tsx });
Recommendation: Register `onTaskUpdate` exactly once (alongside the existing global subscriptions in the store) and have components consume store state instead of attaching their own listeners.

## 21. `StreamingText` conditional return before hooks

MEDIUM

Summary: An early `return` for the real-streaming branch sits above three `useEffect` hooks, violating the Rules of Hooks.

Details: If `isRealStreaming` flips on a mounted instance, React throws "Rendered fewer/more hooks than during the previous render" and crashes the chat view. `MessageBubble` renders `StreamingText` in two ternary branches with different `isRealStreaming` values, so React can reuse the instance across the flip (e.g. when an out-of-order delta reintroduces a completed message id).

Occurrences: src/components/ui/streaming-text.tsx:41-52

if (isRealStreaming) { return ( /* ... */ ); // early return BEFORE the useEffect hooks below } useEffect(() => { /* ... */ });
Recommendation: Hoist all hooks above the conditional return and have the effects no-op when `isRealStreaming` is true (also satisfies the repo's own "hooks at top level only" rule).

## 22. `file://` enrichment links silently stripped (dead feature)

MEDIUM

Summary: Content enrichment rewrites bare paths into `file://` markdown links, but react-markdown's default URL sanitizer drops every `file:` href, so the links are inert.

Details: The renderer passes no custom `urlTransform`, and react-markdown 9.x's `defaultUrlTransform` only allows `http(s)/irc(s)/mailto/xmpp` — every `file://` href becomes `""`, so the click handler bails. The path-enrichment feature (and any `[text](file:///...)` the agent emits) renders as non-functional anchors; unit tests mask the bug by calling the component factory directly.

Occurrences: src/lib/content-enrichment.ts:159-164, src/components/chat/MessageBubble.tsx:180-182, src/components/markdown/EnhancedLink.tsx:80-90

matches.push({ // ... replacement: `[${candidate}](file://${candidate})`, // sanitized to "" at render });
Recommendation: Provide a custom `urlTransform` that whitelists `file:` in addition to the default-safe protocols, keeping `isPathSafe` in `EnhancedLink` as the enforcement point.

## 23. Chat auto-scroll selector never matches

MEDIUM

Summary: The auto-scroll effect queries `[data-messages-end]`, but the sentinel div has no such attribute, so `querySelector` always returns `null`.

Details: `data-messages-end` appears nowhere in `src/`, so auto-scroll on new/streaming messages never fires. Only the one-time on-load jump and the manual scroll-to-bottom button work.

Occurrences: src/pages/Execution.tsx:253-259, src/components/chat/MessageList.tsx:154

const messagesEnd = document.querySelector( '[data-testid="messages-scroll-container"] [data-messages-end]' // matches nothing ); messagesEnd?.scrollIntoView({ behavior: 'smooth' });
Recommendation: Add `data-messages-end` to the sentinel div, or move the auto-scroll logic into `MessageList` where `messagesEndRef` already exists.

## 24. Always-on debug-log listener: unbounded growth + page re-render

MEDIUM

Summary: The `sidecar:log` listener is registered regardless of debug mode, appending to an unbounded array and re-rendering the whole Execution page per log line.

Details: Every sidecar log line appends to `debugLogs` for the page's lifetime and triggers a state update that re-renders the entire `ExecutionPage` (including the full message list) even when the debug panel is hidden. The list also uses `key={index}`.

Occurrences: src/pages/Execution.tsx:217-222, :55, :640-641

api.onDebugLog((log) => { setDebugLogs((prev) => [...prev, log]); // unbounded; re-renders whole page }).then(track);
Recommendation: Register the listener only when debug mode is enabled, cap the array (e.g. last 500 entries), and isolate the debug panel into its own child component so log events don't re-render the chat.

## 25. Full markdown re-parsed on every streaming delta

MEDIUM

Summary: Each partial-message event invalidates the normalize/enrich/extract memos and re-parses the entire accumulated markdown; fake-typing mode re-parses up to ~60×/sec.

Details: `enrichContentWithLinks` performs multiple full-text regex scans with O(matches²) overlap checks, then `ReactMarkdown` re-parses the whole growing string. The `StreamingText` rAF loop calls `children(displayedText)` on every frame, so cost grows quadratically with message length.

Occurrences: src/components/chat/MessageBubble.tsx:57-67, src/components/ui/streaming-text.tsx:82-99

const enrichedContent = useMemo(() => { return enrichContentWithLinks(normalizedContent); // re-runs on every delta }, [normalizedContent]);
Recommendation: Throttle partial-message renders (flush at 30–60 ms intervals) and render plain text during streaming, switching to the full enrich + markdown pipeline only on finalize.

## 26. Rust backend largely untested (4 of 45 files)

MEDIUM

Summary: Only 4 of 45 Rust source files contain `#[cfg(test)]` modules, leaving the persistence layer, command handlers, and sidecar event mapping untested.

Details: Tests exist for `fs_utils`, `workspace_validator`, `commands/skills`, and `git_ops` only. The untested code includes the entire `db/` layer, all Tauri command handlers, and `sidecar.rs` — the exact layer where the status-mapping bug (Finding 7) lives. By contrast the frontend has 26 test files and the sidecar 6 Jest suites.

Occurrences: src-tauri/src/ (45 files; tested: fs_utils.rs, workspace_validator.rs, commands/skills.rs, git_ops.rs)

Frontend (src/): 180 TS/TSX | 26 __tests__ suites Sidecar: 9 TS | 6 Jest suites Rust (src-tauri/src/): 45 .rs | 4 #[cfg(test)] modules
Recommendation: Prioritize unit tests for `sidecar.rs` event mapping and the `db/` modules (cheap with `tempfile`), and add a contract test deserializing `types.ts` fixtures through the Rust serde types to catch IPC drift.

## 27. `get_key_prefix` byte-slice panic on non-ASCII keys

LOW

Summary: The display-prefix helper slices a `String` by byte index, which panics if byte 8 falls inside a multi-byte UTF-8 character.

Details: `&key[..prefix_len]` uses byte indexing; while API keys are normally ASCII, a key (or pasted value) with a multi-byte character near the boundary will panic the command. Low likelihood, but it is a real panic vector in a user-facing path.

Occurrences: src-tauri/src/secure_storage.rs:87-88

let prefix_len = std::cmp::min(8, key.len()); Ok(Some(format!("{}...", &key[..prefix_len]))) // panics on non-char-boundary
Recommendation: Use `key.chars().take(8).collect::<String>()` (character-aware) instead of byte slicing.

## 28. Unserialized concurrent stdin command handling

LOW

Summary: The readline handler is async but not awaited, so commands arriving in quick succession execute concurrently.

Details: `readline` does not await async listeners, so an `abort_session` can interleave with a `start_task` still inside `createSession`/`updateConfig`, or `shutdown` can run mid-task. The `initializePromise` guard only serializes first-time initialization.

Occurrences: src-tauri/sidecar-opencode/src/index.ts:752-764

rl.on('line', async (line: string) => { const msg = JSON.parse(line) as SidecarCommand; await handleMessage(msg); // not awaited by readline -> concurrent execution });
Recommendation: Chain commands through a promise queue (`queue = queue.then(() => handleMessage(msg))`) so they execute in arrival order.

## 29. IPC type contract bypassed with double cast

LOW

Summary: A `tool_use` message is emitted via `as unknown as SidecarEvent`, sending a shape that does not match the declared `TaskMessagePayload`.

Details: `TaskMessagePayload` declares `{ message: Message; parts: Part[] }`, but this emit sends a differently shaped `message` and omits `parts`, hidden behind a double cast. Since `types.ts` is the documented single source of truth, the compiler can no longer catch regressions on either side.

Occurrences: src-tauri/sidecar-opencode/src/index.ts:154-166

send({ type: 'task_message', taskId: data.taskId, payload: { message: toolMessage }, } as unknown as SidecarEvent); // bypasses the typed contract
Recommendation: Widen `TaskMessagePayload` to a discriminated union that includes the `tool_use` shape and remove the cast; audit the other `as SidecarEvent` casts in the file.

## 30. O(n²) per-render scan + unvirtualized chat list

LOW

Summary: A backward scan for the last assistant message runs inside `.map()`, making each render O(n²), and the full message history is rendered without virtualization.

Details: The `lastAssistantIndex` loop executes once per message; combined with re-renders on every streaming delta (Finding 25), long tasks keep the entire framer-motion tree mounted. The filter + map chain is rebuilt un-memoized each render.

Occurrences: src/components/chat/MessageList.tsx:88-102

.map((message, index, filteredMessages) => { let lastAssistantIndex = -1; for (let i = filteredMessages.length - 1; i >= 0; i--) { /* O(n) inside map */ } })
Recommendation: Compute `lastAssistantIndex` once before the map, memoize the filtered array, and consider virtualization (e.g. `@tanstack/react-virtual`) for the history.

## 31. `onAutomationRun*` unsubscribe race leaks listeners

LOW

Summary: Three automation listener helpers return a cleanup that no-ops if invoked before the `listen()` promise resolves, leaking the real unlisten function.

Details: If cleanup runs before `listen()` resolves — guaranteed in React Strict Mode's mount→unmount→mount cycle — `unlisten` is still `null` and the listener fires forever (the Sidebar then double-loads automation runs). A correct implementation (`toSyncUnlisten` with a `pendingCancel` flag) already exists in the codebase but is not used here.

Occurrences: src/lib/tauri-api.ts:1538-1548 (and :1550-1560, :1562-1572)

let unlisten: UnlistenFn | null = null; listen(...).then((fn) => { unlisten = fn; }); return () => { unlisten?.(); }; // no-ops if cleanup runs before listen resolves
Recommendation: Reimplement the three helpers via the existing `toSyncUnlisten` wrapper (or replicate its `pendingCancel` flag).

## 32. Full task-event payloads logged; `console.log` in production

LOW

Summary: Every non-deduped task update is serialized twice into the logging backend, and streaming code contains `console.log` calls the project's own rules forbid.

Details: Full assistant message bodies and tool inputs/outputs (which can contain user file content) are serialized into both `message` and `context` on every event, inflating log volume and persisting potentially sensitive content. Two `console.log` calls fire on every streaming partial/complete event.

Occurrences: src/stores/taskStore.ts:809-814, :1223, :1233

void api.logEvent({ level: 'debug', message: `taskUpdateEvent: ${JSON.stringify(event)}`, // full body context: { ...event }, // duplicated });
Recommendation: Log only event metadata (taskId, type, message id, payload sizes), drop the duplicated `context` spread, and remove the `console.log` calls.

## 33. Oversized god-files concentrate unrelated concerns

LOW

Summary: A handful of files mix many domains and exceed reasonable size, raising merge-conflict surface and hiding bugs.

Details: `tauri-api.ts` (1,825 lines) mixes ~20 domains plus message-normalization parsing; `taskStore.ts` (1,245 lines) embeds a bash-command artifact-extraction regex engine that is pure domain logic. This concentration made the listener-registration bugs (Findings 20 and 31) easy to miss.

Occurrences: src/lib/tauri-api.ts (1,825), src/stores/taskStore.ts (1,245), src/pages/Execution.tsx (678), src/stores/arenaStore.ts (647)

// src/lib/tauri-api.ts — all invoke() calls and event listeners between // the React frontend and the Rust backend live in this single file.
Recommendation: Split `tauri-api.ts` by domain (mirroring `src-tauri/src/commands/`) and extract artifact extraction into `src/lib/artifact-extraction.ts` with its own tests.

## 34. Dead code across the IPC layer

LOW

Summary: Several IPC paths and methods are unreachable: the `shutdown` command bypasses the typed enum, an orphan `'message'` event is emitted with no listener, and ~10 client methods are unused.

Details: The Rust `shutdown` is hand-built JSON rather than a `SidecarCommand` variant, escaping the type-checked contract. The sidecar emits `'message'` that nothing subscribes to, and `OpenCodeClient` exposes many never-called methods, inflating the `pkg` binary and misleading readers about the protocol.

Occurrences: src-tauri/src/sidecar.rs:584-587, src-tauri/sidecar-opencode/src/session-manager.ts:114-117, src/opencode-client.ts (unused methods)

// Send shutdown command via stdin (bypasses SidecarCommand enum) let shutdown_cmd = serde_json::json!({"type": "shutdown"}); let json = serde_json::to_string(&shutdown_cmd).unwrap_or_default();
Recommendation: Add a `Shutdown` variant to `SidecarCommand` and route it through `send_command`; remove the orphan `emit('message', …)` and prune or `@internal`-mark unused client methods.

## 35. No dependency-audit automation; `.gitignore` lacks `.env`

LOW

Summary: CI runs no dependency vulnerability audit, and `.gitignore` has no `.env*` pattern.

Details: Hygiene is otherwise good — all three lockfiles are committed, no secrets are present, and `dist/` + `binaries/` are ignored. But there is no `pnpm audit` / `cargo audit` / Dependabot step, and a future accidentally-created `.env` would be committable.

Occurrences: .github/workflows/test.yml (whole file), .gitignore:1-43

node_modules dist dist-ssr *.local # (no .env* pattern)
Recommendation: Add `.env*` to `.gitignore` and a dependency-audit step (or Dependabot/Renovate config) to `.github/`.