From d2d4746d5c87b01c6e54ca4d67e8d1edc28b8876 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Wed, 29 Apr 2026 00:52:27 +0200 Subject: [PATCH] feat: add vault ai agent permission modes --- docs/ABSTRACTIONS.md | 3 +- docs/ARCHITECTURE.md | 12 +- docs/GETTING-STARTED.md | 17 +- .../0092-vault-ai-agent-permission-modes.md | 39 +++ docs/adr/README.md | 1 + src-tauri/src/ai_agents.rs | 177 ++++++++++++- src-tauri/src/claude_cli.rs | 241 ++++++++++++------ src-tauri/src/opencode_cli.rs | 102 +++++++- src-tauri/src/opencode_config.rs | 36 ++- src-tauri/src/opencode_events_tests.rs | 163 ++++++++---- src-tauri/src/pi_cli.rs | 102 +++++++- src-tauri/src/pi_config.rs | 34 ++- src-tauri/src/pi_events_tests.rs | 45 ++++ src/components/AiMessage.tsx | 23 +- src/components/AiPanel.test.tsx | 98 ++++++- src/components/AiPanel.tsx | 5 + src/components/AiPanelChrome.tsx | 144 ++++++++--- src/components/useAiPanelController.ts | 52 +++- src/hooks/useCliAiAgent.test.ts | 38 ++- src/hooks/useCliAiAgent.ts | 11 +- src/hooks/useVaultConfig.ts | 6 +- src/lib/aiAgentConversation.ts | 20 +- src/lib/aiAgentPermissionMode.test.ts | 30 +++ src/lib/aiAgentPermissionMode.ts | 26 ++ src/lib/aiAgentSession.ts | 9 + src/types.ts | 2 + src/utils/streamAiAgent.test.ts | 2 + src/utils/streamAiAgent.ts | 7 + src/utils/vaultConfigStore.test.ts | 60 +++++ src/utils/vaultConfigStore.ts | 19 +- ui-design.pen | 109 ++++++++ 31 files changed, 1402 insertions(+), 231 deletions(-) create mode 100644 docs/adr/0092-vault-ai-agent-permission-modes.md create mode 100644 src/lib/aiAgentPermissionMode.test.ts create mode 100644 src/lib/aiAgentPermissionMode.ts create mode 100644 src/utils/vaultConfigStore.test.ts diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 5450003d..0a47197e 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -676,7 +676,8 @@ No indexing step required — search runs directly against the filesystem. Per-vault settings stored locally and scoped by vault path: - Managed by `useVaultConfig` hook and `vaultConfigStore` -- Settings: zoom, view mode, editor mode, note layout, tag colors, status colors, property display modes, Inbox/All Notes note-list column overrides, explicit organization workflow toggle +- Settings: zoom, view mode, editor mode, note layout, tag colors, status colors, property display modes, Inbox/All Notes note-list column overrides, explicit organization workflow toggle, AI agent permission mode (`safe` / `power_user`) +- Missing, null, and unknown AI agent permission modes normalize to `safe`; the AI panel can switch modes per vault, preserving the transcript and applying the new mode only to the next agent run - One-time migration from localStorage (`configMigration.ts`) ### AI Guidance Files diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3d561aaa..b44a2b41 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -224,9 +224,9 @@ Notes can be opened in separate Tauri windows for focused editing. Secondary win Full agent mode — spawns the selected local CLI agent as a subprocess with tool access and MCP vault integration. -1. **Frontend** (`AiPanel` + `useCliAiAgent` + `aiAgentSession.ts` + `aiAgents.ts`) — one normalized session lifecycle for message state, reasoning blocks, tool action cards, response display, onboarding, and default-agent selection -2. **Backend** (`ai_agents.rs`) — normalizes agent availability and streaming, dispatching to per-agent adapters -3. **Agent adapters** — Claude Code still uses `claude_cli.rs` with `acceptEdits`, strict Tolaria MCP config, a file/search-only built-in tool list, hidden Windows subprocess launches, and closed stdin for print-mode subprocesses so Windows launches receive EOF; Codex runs through `codex --sandbox workspace-write --ask-for-approval never exec --json`; OpenCode runs through `opencode run --format json`; Pi runs through `pi --mode json --no-session` with `npm:pi-mcp-adapter`. OpenCode and Pi both launch from the active vault cwd with closed stdin and transient MCP config. All app-launched paths use hidden Windows launches and avoid dangerous permission-bypass flags. +1. **Frontend** (`AiPanel` + `useCliAiAgent` + `aiAgentSession.ts` + `aiAgents.ts`) — one normalized session lifecycle for message state, reasoning blocks, tool action cards, response display, onboarding, default-agent selection, and the per-vault Safe / Power User permission mode shown in the panel header +2. **Backend** (`ai_agents.rs`) — normalizes agent availability, streaming, and the request permission mode before dispatching to per-agent adapters +3. **Agent adapters** — Claude Code still uses `claude_cli.rs` with `acceptEdits`, strict Tolaria MCP config, and a scoped tool list: Safe enables file/search/edit tools, while Power User adds Bash without using dangerous bypass flags. Codex runs through `codex --sandbox workspace-write --ask-for-approval never exec --json` in both modes. OpenCode runs through `opencode run --format json` with transient permissions: Safe denies bash and external directories, while Power User allows bash but still denies external directories. Pi runs through `pi --mode json --no-session` with `npm:pi-mcp-adapter`; both modes currently share the same transient MCP config. OpenCode and Pi both launch from the active vault cwd with closed stdin and transient MCP config. All app-launched paths use hidden Windows launches and avoid dangerous permission-bypass flags. 4. **MCP Integration** — Claude receives the generated MCP config file path, Codex receives the same Tolaria MCP server via transient `-c mcp_servers.tolaria.*` config overrides, OpenCode receives it through `OPENCODE_CONFIG_CONTENT`, and Pi receives it through a temporary `PI_CODING_AGENT_DIR/mcp.json` consumed by `pi-mcp-adapter` CLI-agent availability intentionally does not depend only on the desktop app's inherited `PATH`. The detectors check the current process path, the user's login shell, and supported local/toolchain install locations such as native `~/.local/bin`, local `~/.claude/local`, Mise/asdf shims, nvm-managed Node installs, npm-global, Homebrew, Windows `%APPDATA%\npm`/pnpm/Scoop shims, Windows `.exe` launchers, and the macOS Codex app resource path so first-run onboarding works on fresh macOS and Windows installs. @@ -243,7 +243,7 @@ sequenceDiagram U->>FE: sendMessage(text, references) FE->>FE: buildContextSnapshot(activeNote, linkedNotes, openTabs) - FE->>R: invoke('stream_ai_agent', {agent, message, systemPrompt, vaultPath}) + FE->>R: invoke('stream_ai_agent', {agent, message, systemPrompt, vaultPath, permissionMode}) R->>R: pick adapter for claude_code, codex, opencode, or pi R->>C: spawn agent with MCP-enabled config @@ -788,14 +788,14 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks: | `useTabManagement` | Navigation history, note switching | Note navigation lifecycle | | `useVaultSwitcher` | `vaultPath`, `extraVaults` | Vault switching | | `useTheme` | Editor theme CSS vars and theme-mode bridge | Editor typography and app theme runtime | -| `useCliAiAgent` | `messages`, `status`, tool actions | Selected AI agent conversation backed by the shared session pipeline | +| `useCliAiAgent` | `messages`, `status`, tool actions | Selected AI agent conversation backed by the shared session pipeline and vault permission mode | | `useAutoSync` | Sync interval, pull/push state | Git auto-sync | | `useAutoGit` | Last activity timestamp, idle/inactive checkpoint triggers | Automatic commit/push checkpoints | | `useCommitFlow` | Commit dialog state, shared manual/automatic checkpoint runner | Git commit/push orchestration | | `useGitRemoteStatus` | `remoteStatus`, `refreshRemoteStatus()` | On-demand remote detection for commit UI | | `useUnifiedSearch` | Query, results, loading state | Keyword search | | `useSettings` | App settings (telemetry, release channel, theme mode, UI language, auto-sync interval, AutoGit thresholds, default AI agent, Gitignored-content visibility) | Persistent settings | -| `useVaultConfig` | Per-vault UI preferences | Vault-specific config | +| `useVaultConfig` | Per-vault UI preferences, AI permission mode | Vault-specific config | | `appCommandDispatcher` | Canonical shortcut/menu command IDs | Shared execution path for renderer and native menu commands | Data flows unidirectionally: `App` passes data and callbacks as props to child components. No child-to-child communication — everything goes through `App`. diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index 5db3268a..f83022ec 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -79,7 +79,7 @@ tolaria/ │ │ ├── RawEditorView.tsx # CodeMirror raw editor │ │ ├── Inspector.tsx # Fourth panel: metadata + relationships │ │ ├── DynamicPropertiesPanel.tsx # Editable frontmatter properties -│ │ ├── AiPanel.tsx # AI agent panel (selected CLI agent) +│ │ ├── AiPanel.tsx # AI agent panel (selected CLI agent + per-vault permission mode) │ │ ├── AiMessage.tsx # Agent message display │ │ ├── AiActionCard.tsx # Agent tool action cards │ │ ├── AiAgentsOnboardingPrompt.tsx # First-launch AI agent installer prompt @@ -114,6 +114,7 @@ tolaria/ │ │ ├── useNoteCreation.ts # Note/type creation │ │ ├── useNoteRename.ts # Note renaming + wikilink updates │ │ ├── useCliAiAgent.ts # Selected AI agent state + normalized session pipeline +│ │ ├── aiAgentPermissionMode.ts # Safe/Power User mode normalization + labels │ │ ├── useAiAgentsStatus.ts # Claude/Codex/OpenCode/Pi availability polling │ │ ├── useAiAgentPreferences.ts # Default-agent persistence + cycling │ │ ├── useAiActivity.ts # MCP UI bridge listener @@ -261,8 +262,8 @@ tolaria/ | `src-tauri/src/frontmatter/ops.rs` | YAML manipulation — how properties are updated/deleted in files. | | `src-tauri/src/git/` | All git operations (clone, commit, pull, push, conflicts, pulse, add-remote). | | `src-tauri/src/search.rs` | Keyword search — scans vault files with walkdir. | -| `src-tauri/src/ai_agents.rs` | Shared CLI-agent availability checks, adapter dispatch, and stream normalization. | -| `src-tauri/src/claude_cli.rs` | Claude CLI subprocess spawning + NDJSON stream parsing. | +| `src-tauri/src/ai_agents.rs` | Shared CLI-agent availability checks, adapter dispatch, stream normalization, and permission-mode request normalization. | +| `src-tauri/src/claude_cli.rs` | Claude CLI subprocess spawning, permission-mode tool mapping, and NDJSON stream parsing. | | `src-tauri/src/pi_cli.rs` | Pi subprocess spawning through JSON mode and transient MCP adapter config. | | `src-tauri/src/app_updater.rs` | Desktop updater bridge — selects alpha/stable manifests and streams install progress. | @@ -282,9 +283,10 @@ tolaria/ | File | Why it matters | |------|---------------| -| `src/components/AiPanel.tsx` | AI agent panel — selected CLI agent with tool execution, reasoning, and actions. | +| `src/components/AiPanel.tsx` | AI agent panel — selected CLI agent with tool execution, reasoning, actions, and per-vault permission mode. | | `src/hooks/useCliAiAgent.ts` | Thin React owner for the selected CLI agent session state. | | `src/lib/aiAgentSession.ts` | Single message/session lifecycle for prompt normalization, history, streaming, and reset behavior. | +| `src/lib/aiAgentPermissionMode.ts` | Safe/Power User mode normalization, display labels, and local transcript marker text. | | `src/lib/aiAgentFileOperations.ts` | Detects agent-created or modified vault files from normalized tool inputs. | | `src/lib/aiAgents.ts` | Supported agent definitions, status normalization, and default-agent helpers. | | `src/utils/ai-context.ts` | Context snapshot builder for AI conversations. | @@ -304,7 +306,7 @@ tolaria/ | `src/lib/releaseChannel.ts` | Normalizes persisted updater-channel values (`stable` default, optional `alpha`). | | `src/lib/appUpdater.ts` | Frontend wrapper for channel-aware updater commands. | | `src/hooks/useMainWindowSizeConstraints.ts` | Derives the main-window minimum width from the visible panes and asks Tauri to grow back to fit wider layouts. | -| `src/hooks/useVaultConfig.ts` | Per-vault local UI preferences (zoom, view mode, colors, Inbox columns, explicit organization workflow). | +| `src/hooks/useVaultConfig.ts` | Per-vault local UI preferences (zoom, view mode, colors, Inbox columns, explicit organization workflow, AI permission mode). | | `src/components/SettingsPanel.tsx` | Settings UI for telemetry, release channel, sync interval, UI language, default AI agent, and the vault-level explicit organization toggle. | | `src/hooks/useUpdater.ts` | In-app updates using the selected alpha/stable feed. | @@ -420,8 +422,9 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/.spec.ts 1. **Agent system prompt**: Edit `src/utils/ai-agent.ts` (inline system prompt string) 2. **Context building**: Edit `src/utils/ai-context.ts` for what data is sent to the agent 3. **Tool action display**: Edit `src/components/AiActionCard.tsx` -4. **Claude CLI arguments**: Edit `src-tauri/src/claude_cli.rs` (`run_agent_stream()`; keep app-managed launches on strict Tolaria MCP config, `acceptEdits`, and the scoped file/search tool list) -5. **Shared agent adapters / Codex/Pi args**: Edit `src-tauri/src/ai_agents.rs` plus the per-agent adapter modules (keep Codex sandboxed with active-vault `workspace-write`, keep Pi on transient MCP config, and do not use dangerous permission bypasses unless an ADR explicitly designs a new mode) +4. **Permission-mode UI and request plumbing**: Edit `src/lib/aiAgentPermissionMode.ts`, `src/components/AiPanel*.tsx`, `src/hooks/useCliAiAgent.ts`, and `src/utils/streamAiAgent.ts` +5. **Claude CLI arguments**: Edit `src-tauri/src/claude_cli.rs` (`run_agent_stream()`; keep app-managed launches on strict Tolaria MCP config, `acceptEdits`, and the scoped file/search tool list unless the permission mode explicitly broadens it) +6. **Shared agent adapters / Codex/Pi args**: Edit `src-tauri/src/ai_agents.rs` plus the per-agent adapter modules (keep Codex sandboxed with active-vault `workspace-write`, keep Pi on transient MCP config, and do not use dangerous permission bypasses unless an ADR explicitly designs a new mode) ### Work with external MCP setup diff --git a/docs/adr/0092-vault-ai-agent-permission-modes.md b/docs/adr/0092-vault-ai-agent-permission-modes.md new file mode 100644 index 00000000..aba54644 --- /dev/null +++ b/docs/adr/0092-vault-ai-agent-permission-modes.md @@ -0,0 +1,39 @@ +--- +type: ADR +id: "0092" +title: "Vault-scoped AI agent permission modes" +status: active +date: 2026-04-28 +--- + +## Context + +ADR-0074 established explicit setup and least-privilege defaults for desktop AI tools. The in-app AI panel now supports multiple local CLI agents, and users need a clear per-vault way to choose whether an agent should stay in the narrow vault-safe profile or use broader local-work tools for that vault. + +The mode must be visible at the point of use, must not mutate global CLI settings, and must not silently restore dangerous bypass flags. Existing transcripts should remain intact when the mode changes because a change applies to the next agent run, not to a process that is already streaming. + +## Decision + +**Tolaria stores an `ai_agent_permission_mode` per vault with values `safe` and `power_user`, defaulting missing or null values to `safe`, and passes that normalized mode through the AI panel stream request into each CLI adapter.** + +The AI panel header displays the current mode and offers a compact Vault Safe / Power User control that is disabled while an agent run is active. Changing the mode preserves the transcript and inserts a local transcript marker. + +Adapter mappings remain conservative: +- Claude Code Safe keeps `acceptEdits`, strict Tolaria MCP config, and file/search/edit tools only; Power User adds Bash to the allowed tool list without using `--dangerously-skip-permissions`. +- Codex keeps the active-vault `workspace-write` sandbox and `--ask-for-approval never` in both modes. +- OpenCode uses transient `OPENCODE_CONFIG_CONTENT`; Safe denies bash and external directories, while Power User allows bash but still denies external directories. +- Pi receives the mode on the adapter request path; both modes currently use the same transient MCP adapter config. + +## Options Considered + +- **Per-vault Safe / Power User modes** (chosen): makes the permission surface explicit where the agent is used and preserves least-privilege defaults for each vault. +- **Global app setting**: simpler storage, but a single toggle can over-apply a power-user profile to unrelated vaults. +- **Dangerous bypass mode**: maximizes CLI freedom, but violates ADR-0074's least-privilege boundary and needs a separate explicit security decision. +- **Adapter-specific UI switches**: exposes too much implementation detail and makes cross-agent behavior harder to reason about. + +## Consequences + +- Vault config normalization owns the safe default for old vaults and malformed values. +- Agent requests now carry a permission mode through frontend and Rust boundaries, so new adapters must choose an explicit mapping. +- Power User is intentionally not equivalent across agents; where an adapter lacks a safe broader local-work switch, both modes may map to the same conservative behavior and must document that with tests. +- Any future dangerous mode requires a new ADR and separate UI language. diff --git a/docs/adr/README.md b/docs/adr/README.md index 10dc7242..fac6e836 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -144,3 +144,4 @@ proposed → active → superseded | [0089](0089-active-vault-filesystem-watcher.md) | Active vault filesystem watcher | active | | [0090](0090-pi-cli-agent-adapter.md) | Pi CLI agent adapter | active | | [0091](0091-gemini-cli-external-ai-setup.md) | Gemini CLI external AI setup | active | +| [0092](0092-vault-ai-agent-permission-modes.md) | Vault-scoped AI agent permission modes | active | diff --git a/src-tauri/src/ai_agents.rs b/src-tauri/src/ai_agents.rs index 3b63a479..59454843 100644 --- a/src-tauri/src/ai_agents.rs +++ b/src-tauri/src/ai_agents.rs @@ -12,6 +12,19 @@ pub enum AiAgentId { Pi, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AiAgentPermissionMode { + Safe, + PowerUser, +} + +impl Default for AiAgentPermissionMode { + fn default() -> Self { + Self::Safe + } +} + #[derive(Debug, Clone, Serialize)] pub struct AiAgentAvailability { pub installed: bool, @@ -61,6 +74,13 @@ pub struct AiAgentStreamRequest { pub message: String, pub system_prompt: Option, pub vault_path: String, + pub permission_mode: Option, +} + +impl AiAgentStreamRequest { + fn permission_mode(&self) -> AiAgentPermissionMode { + self.permission_mode.unwrap_or_default() + } } pub fn get_ai_agents_status() -> AiAgentsStatus { @@ -76,12 +96,14 @@ pub fn run_ai_agent_stream(request: AiAgentStreamRequest, mut emit: F) -> Res where F: FnMut(AiAgentStreamEvent), { + let permission_mode = request.permission_mode(); match request.agent { AiAgentId::ClaudeCode => { let mapped = crate::claude_cli::AgentStreamRequest { message: request.message, system_prompt: request.system_prompt, vault_path: request.vault_path, + permission_mode, }; crate::claude_cli::run_agent_stream(mapped, |event| { if let Some(mapped_event) = map_claude_event(event) { @@ -95,6 +117,7 @@ where message: request.message, system_prompt: request.system_prompt, vault_path: request.vault_path, + permission_mode, }; crate::opencode_cli::run_agent_stream(mapped, emit) } @@ -103,6 +126,7 @@ where message: request.message, system_prompt: request.system_prompt, vault_path: request.vault_path, + permission_mode, }; crate::pi_cli::run_agent_stream(mapped, emit) } @@ -264,15 +288,26 @@ fn find_existing_binary(candidates: Vec) -> Option { candidates.into_iter().find(|candidate| candidate.exists()) } -fn run_codex_agent_stream(request: AiAgentStreamRequest, mut emit: F) -> Result +fn run_codex_agent_stream(request: AiAgentStreamRequest, emit: F) -> Result where F: FnMut(AiAgentStreamEvent), { let binary = find_codex_binary()?; + run_codex_agent_stream_with_binary(&binary, request, emit) +} + +fn run_codex_agent_stream_with_binary( + binary: &Path, + request: AiAgentStreamRequest, + mut emit: F, +) -> Result +where + F: FnMut(AiAgentStreamEvent), +{ let args = build_codex_args(&request)?; let prompt = build_codex_prompt(&request); - let mut command = build_codex_command(&binary, args, prompt, &request.vault_path); + let mut command = build_codex_command(binary, args, prompt, &request.vault_path); let mut child = command .spawn() @@ -518,13 +553,85 @@ mod tests { use super::*; use std::ffi::OsStr; + #[cfg(unix)] + fn executable_script(dir: &Path, name: &str, body: &str) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + + let script = dir.join(name); + std::fs::write(&script, format!("#!/bin/sh\n{body}")).unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + script + } + + fn codex_request( + vault_path: &Path, + permission_mode: Option, + ) -> AiAgentStreamRequest { + AiAgentStreamRequest { + agent: AiAgentId::Codex, + message: "Summarize".into(), + system_prompt: None, + vault_path: vault_path.to_string_lossy().into_owned(), + permission_mode, + } + } + + fn assert_codex_workspace_write_contract(args: &[String]) { + let prefix = [ + "--sandbox", + "workspace-write", + "--ask-for-approval", + "never", + ]; + + assert_eq!(&args[..prefix.len()], prefix); + assert!(!args.iter().any(|arg| arg == "danger-full-access")); + assert!(!args + .iter() + .any(|arg| arg == "--dangerously-bypass-approvals-and-sandbox")); + } + + #[cfg(unix)] + fn run_codex_script(body: &str) -> (String, Vec) { + let dir = tempfile::tempdir().unwrap(); + let vault = tempfile::tempdir().unwrap(); + let binary = executable_script(dir.path(), "codex", body); + let mut events = Vec::new(); + let thread_id = run_codex_agent_stream_with_binary( + &binary, + codex_request(vault.path(), Some(AiAgentPermissionMode::Safe)), + |event| events.push(event), + ) + .unwrap(); + + (thread_id, events) + } + + fn assert_codex_text_flow(events: &[AiAgentStreamEvent], session: &str, text_delta: &str) { + assert!(matches!( + &events[0], + AiAgentStreamEvent::Init { session_id } if session_id == session + )); + assert!(matches!( + &events[1], + AiAgentStreamEvent::TextDelta { text } if text == text_delta + )); + assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done))); + } + #[test] fn normalize_status_contains_all_agents() { let status = get_ai_agents_status(); - assert!(matches!(status.claude_code.installed, true | false)); - assert!(matches!(status.codex.installed, true | false)); - assert!(matches!(status.opencode.installed, true | false)); - assert!(matches!(status.pi.installed, true | false)); + let install_flags = [ + status.claude_code.installed, + status.codex.installed, + status.opencode.installed, + status.pi.installed, + ]; + + assert!(install_flags + .iter() + .all(|installed| matches!(installed, true | false))); } #[test] @@ -534,6 +641,7 @@ mod tests { message: "Rename the note".into(), system_prompt: Some("Be concise".into()), vault_path: "/tmp/vault".into(), + permission_mode: Some(AiAgentPermissionMode::Safe), }); assert!(prompt.starts_with("System instructions:\nBe concise")); @@ -547,19 +655,33 @@ mod tests { message: "Rename the note".into(), system_prompt: None, vault_path: "/tmp/vault".into(), + permission_mode: None, }) { - assert_eq!(args[0], "--sandbox"); - assert_eq!(args[1], "workspace-write"); - assert_eq!(args[2], "--ask-for-approval"); - assert_eq!(args[3], "never"); assert_eq!(args[4], "exec"); - assert!(!args.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string())); - assert!(!args.contains(&"danger-full-access".to_string())); + assert_codex_workspace_write_contract(&args); assert!(args.contains(&"--json".to_string())); assert!(args.contains(&"-C".to_string())); } } + #[test] + fn codex_permission_modes_keep_workspace_write_without_dangerous_bypass() { + for permission_mode in [ + AiAgentPermissionMode::Safe, + AiAgentPermissionMode::PowerUser, + ] { + if let Ok(args) = build_codex_args(&AiAgentStreamRequest { + agent: AiAgentId::Codex, + message: "Rename the note".into(), + system_prompt: None, + vault_path: "/tmp/vault".into(), + permission_mode: Some(permission_mode), + }) { + assert_codex_workspace_write_contract(&args); + } + } + } + #[test] fn build_codex_command_keeps_agent_process_contract() { let binary = PathBuf::from("codex"); @@ -579,6 +701,37 @@ mod tests { assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/vault"))); } + #[cfg(unix)] + #[test] + fn run_codex_agent_stream_reads_ndjson_and_returns_thread_id() { + let (thread_id, events) = run_codex_script( + r#"printf '%s\n' '{"type":"thread.started","thread_id":"thread_1"}' +printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_message","text":"Done"}}' +"#, + ); + + assert_eq!(thread_id, "thread_1"); + assert_codex_text_flow(&events, "thread_1", "Done"); + } + + #[cfg(unix)] + #[test] + fn run_codex_agent_stream_reports_nonzero_exit_errors() { + let (thread_id, events) = run_codex_script( + r#"printf '%s\n' '{"type":"thread.started","thread_id":"thread_1"}' +printf '%s\n' 'login required' >&2 +exit 2 +"#, + ); + + assert_eq!(thread_id, "thread_1"); + assert!(events.iter().any(|event| matches!( + event, + AiAgentStreamEvent::Error { message } if message.contains("not authenticated") + ))); + assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done))); + } + #[test] fn codex_binary_candidates_include_supported_macos_installs() { let home = PathBuf::from("/Users/alex"); diff --git a/src-tauri/src/claude_cli.rs b/src-tauri/src/claude_cli.rs index 2dfca348..518b52d5 100644 --- a/src-tauri/src/claude_cli.rs +++ b/src-tauri/src/claude_cli.rs @@ -1,3 +1,4 @@ +use crate::ai_agents::AiAgentPermissionMode; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::io::BufRead; @@ -56,6 +57,7 @@ pub struct AgentStreamRequest { pub message: String, pub system_prompt: Option, pub vault_path: String, + pub permission_mode: AiAgentPermissionMode, } // --------------------------------------------------------------------------- @@ -290,7 +292,7 @@ fn build_agent_args(req: &AgentStreamRequest) -> Result, String> { "--permission-mode".into(), "acceptEdits".into(), "--tools".into(), - "Read,Edit,MultiEdit,Write,Glob,Grep,LS".into(), + agent_tools(req.permission_mode).into(), "--no-session-persistence".into(), ]; @@ -304,6 +306,13 @@ fn build_agent_args(req: &AgentStreamRequest) -> Result, String> { Ok(args) } +fn agent_tools(permission_mode: AiAgentPermissionMode) -> &'static str { + match permission_mode { + AiAgentPermissionMode::Safe => "Read,Edit,MultiEdit,Write,Glob,Grep,LS", + AiAgentPermissionMode::PowerUser => "Read,Edit,MultiEdit,Write,Glob,Grep,LS,Bash", + } +} + // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- @@ -663,6 +672,82 @@ mod tests { use std::ffi::OsString; use std::process::Command; + macro_rules! chat_request { + ($message:expr, None, None $(,)?) => { + ChatStreamRequest { + message: $message.into(), + system_prompt: None, + session_id: None, + } + }; + ($message:expr, Some($system_prompt:expr), None $(,)?) => { + ChatStreamRequest { + message: $message.into(), + system_prompt: Some($system_prompt.to_string()), + session_id: None, + } + }; + ($message:expr, None, Some($session_id:expr) $(,)?) => { + ChatStreamRequest { + message: $message.into(), + system_prompt: None, + session_id: Some($session_id.to_string()), + } + }; + } + + macro_rules! agent_request { + ($message:expr, None, $permission_mode:expr $(,)?) => { + AgentStreamRequest { + message: $message.into(), + system_prompt: None, + vault_path: "/tmp/vault".into(), + permission_mode: $permission_mode, + } + }; + ($message:expr, Some($system_prompt:expr), $permission_mode:expr $(,)?) => { + AgentStreamRequest { + message: $message.into(), + system_prompt: Some($system_prompt.to_string()), + vault_path: "/tmp/vault".into(), + permission_mode: $permission_mode, + } + }; + } + + macro_rules! assert_args_contain { + ($args:expr, [$($value:expr),+ $(,)?] $(,)?) => { + $( + assert!($args.contains(&$value.to_string()), "missing {}", $value); + )+ + }; + } + + macro_rules! assert_args_lack { + ($args:expr, [$($value:expr),+ $(,)?] $(,)?) => { + $( + assert!(!$args.contains(&$value.to_string()), "unexpected {}", $value); + )+ + }; + } + + macro_rules! assert_no_arg_contains { + ($args:expr, $fragment:expr $(,)?) => { + assert!(!$args.iter().any(|arg| arg.contains($fragment))); + }; + } + + fn assert_binary_candidates_include(home: &Path, expected: &[PathBuf]) { + let candidates = claude_binary_candidates_for_home(home); + for candidate in expected { + assert!( + candidates.contains(candidate), + "missing {}", + candidate.display() + ); + } + } + #[test] fn check_cli_returns_status() { let status = check_cli(); @@ -685,6 +770,38 @@ mod tests { assert!(candidates.contains(&claude), "missing {}", claude.display()); } + #[test] + fn agent_args_use_safe_mode_without_bash_by_default() { + let args = build_agent_args(&agent_request!( + "Rename the note", + None, + AiAgentPermissionMode::Safe, + )) + .unwrap(); + + assert_args_contain!( + args, + ["--strict-mcp-config", "--permission-mode", "acceptEdits"] + ); + assert_args_contain!(args, ["Read,Edit,MultiEdit,Write,Glob,Grep,LS"]); + assert_no_arg_contains!(args, "Bash"); + assert_args_lack!(args, ["--dangerously-skip-permissions"]); + } + + #[test] + fn agent_args_allow_bash_in_power_user_mode_without_dangerous_bypass() { + let args = build_agent_args(&agent_request!( + "Rename the note", + None, + AiAgentPermissionMode::PowerUser, + )) + .unwrap(); + + assert_args_contain!(args, ["--strict-mcp-config"]); + assert_args_contain!(args, ["Read,Edit,MultiEdit,Write,Glob,Grep,LS,Bash"]); + assert_args_lack!(args, ["--dangerously-skip-permissions"]); + } + #[test] fn build_mcp_config_is_valid_json() { if let Ok(config_str) = build_mcp_config("/tmp/test-vault") { @@ -1236,52 +1353,31 @@ mod tests { #[test] fn build_chat_args_basic() { - let req = ChatStreamRequest { - message: "hello".into(), - system_prompt: None, - session_id: None, - }; - let args = build_chat_args(&req); - assert!(args.contains(&"-p".to_string())); - assert!(args.contains(&"hello".to_string())); - assert!(args.contains(&"stream-json".to_string())); - assert!(!args.contains(&"--system-prompt".to_string())); - assert!(!args.contains(&"--resume".to_string())); + let args = build_chat_args(&chat_request!("hello", None, None)); + + assert_args_contain!(args, ["-p", "hello", "stream-json"]); + assert_args_lack!(args, ["--system-prompt", "--resume"]); } #[test] fn build_chat_args_with_system_prompt() { - let req = ChatStreamRequest { - message: "hi".into(), - system_prompt: Some("You are helpful.".into()), - session_id: None, - }; - let args = build_chat_args(&req); - assert!(args.contains(&"--system-prompt".to_string())); - assert!(args.contains(&"You are helpful.".to_string())); + let args = build_chat_args(&chat_request!("hi", Some("You are helpful."), None)); + + assert_args_contain!(args, ["--system-prompt", "You are helpful."]); } #[test] fn build_chat_args_empty_system_prompt_is_skipped() { - let req = ChatStreamRequest { - message: "hi".into(), - system_prompt: Some(String::new()), - session_id: None, - }; - let args = build_chat_args(&req); + let args = build_chat_args(&chat_request!("hi", Some(""), None)); + assert!(!args.contains(&"--system-prompt".to_string())); } #[test] fn build_chat_args_with_session_id() { - let req = ChatStreamRequest { - message: "continue".into(), - system_prompt: None, - session_id: Some("sess-abc".into()), - }; - let args = build_chat_args(&req); - assert!(args.contains(&"--resume".to_string())); - assert!(args.contains(&"sess-abc".to_string())); + let args = build_chat_args(&chat_request!("continue", None, Some("sess-abc"))); + + assert_args_contain!(args, ["--resume", "sess-abc"]); } // --- build_agent_args --- @@ -1289,46 +1385,44 @@ mod tests { #[test] fn build_agent_args_basic() { // build_agent_args calls build_mcp_config which needs mcp_server_dir - if let Ok(args) = build_agent_args(&AgentStreamRequest { - message: "create note".into(), - system_prompt: None, - vault_path: "/tmp/vault".into(), - }) { - assert!(args.contains(&"-p".to_string())); - assert!(args.contains(&"create note".to_string())); - assert!(args.contains(&"--mcp-config".to_string())); - assert!(args.contains(&"--strict-mcp-config".to_string())); - assert!(args.contains(&"--permission-mode".to_string())); - assert!(args.contains(&"acceptEdits".to_string())); - assert!(args.contains(&"--tools".to_string())); - assert!(args.contains(&"Read,Edit,MultiEdit,Write,Glob,Grep,LS".to_string())); - assert!(!args.contains(&"--dangerously-skip-permissions".to_string())); - assert!(!args.contains(&"bypassPermissions".to_string())); - assert!(!args.contains(&"Bash".to_string())); - assert!(args.contains(&"--no-session-persistence".to_string())); - assert!(!args.contains(&"--append-system-prompt".to_string())); + if let Ok(args) = build_agent_args(&agent_request!( + "create note", + None, + AiAgentPermissionMode::Safe, + )) { + assert_args_contain!(args, ["-p", "create note", "--mcp-config"]); + assert_args_contain!(args, ["--strict-mcp-config", "--permission-mode"]); + assert_args_contain!(args, ["acceptEdits", "--tools"]); + assert_args_contain!(args, ["Read,Edit,MultiEdit,Write,Glob,Grep,LS"]); + assert_args_contain!(args, ["--no-session-persistence"]); + assert_args_lack!( + args, + [ + "--dangerously-skip-permissions", + "bypassPermissions", + "--append-system-prompt", + ], + ); + assert_no_arg_contains!(args, "Bash"); } } #[test] fn build_agent_args_with_system_prompt() { - if let Ok(args) = build_agent_args(&AgentStreamRequest { - message: "do it".into(), - system_prompt: Some("Act as expert.".into()), - vault_path: "/tmp/v".into(), - }) { - assert!(args.contains(&"--append-system-prompt".to_string())); - assert!(args.contains(&"Act as expert.".to_string())); + if let Ok(args) = build_agent_args(&agent_request!( + "do it", + Some("Act as expert."), + AiAgentPermissionMode::Safe, + )) { + assert_args_contain!(args, ["--append-system-prompt", "Act as expert."]); } } #[test] fn build_agent_args_empty_system_prompt_is_skipped() { - if let Ok(args) = build_agent_args(&AgentStreamRequest { - message: "x".into(), - system_prompt: Some(String::new()), - vault_path: "/tmp/v".into(), - }) { + if let Ok(args) = + build_agent_args(&agent_request!("x", Some(""), AiAgentPermissionMode::Safe)) + { assert!(!args.contains(&"--append-system-prompt".to_string())); } } @@ -1338,7 +1432,6 @@ mod tests { #[test] fn claude_binary_candidates_include_supported_local_and_toolchain_installs() { let home = PathBuf::from("/Users/alex"); - let candidates = claude_binary_candidates_for_home(&home); let expected = [ home.join(".local/bin/claude"), home.join(".claude/local/claude"), @@ -1346,32 +1439,19 @@ mod tests { home.join(".npm-global/bin/claude"), ]; - for candidate in expected { - assert!( - candidates.contains(&candidate), - "missing {}", - candidate.display() - ); - } + assert_binary_candidates_include(&home, &expected); } #[test] fn claude_binary_candidates_include_windows_exe_installs() { let home = PathBuf::from(r"C:\Users\alex"); - let candidates = claude_binary_candidates_for_home(&home); let expected = [ home.join(".local/bin/claude.exe"), home.join(".claude/local/claude.exe"), home.join("AppData/Roaming/npm/claude.cmd"), ]; - for candidate in expected { - assert!( - candidates.contains(&candidate), - "missing {}", - candidate.display() - ); - } + assert_binary_candidates_include(&home, &expected); } #[test] @@ -1427,6 +1507,7 @@ mod tests { message: "test".into(), system_prompt: Some("sys".into()), vault_path: "/tmp/nonexistent".into(), + permission_mode: AiAgentPermissionMode::Safe, }; let mut events = vec![]; let result = run_agent_stream(req, |e| events.push(e)); diff --git a/src-tauri/src/opencode_cli.rs b/src-tauri/src/opencode_cli.rs index 71fe58ea..176aea3f 100644 --- a/src-tauri/src/opencode_cli.rs +++ b/src-tauri/src/opencode_cli.rs @@ -1,22 +1,35 @@ -use crate::ai_agents::{AiAgentAvailability, AiAgentStreamEvent}; +use crate::ai_agents::{AiAgentAvailability, AiAgentPermissionMode, AiAgentStreamEvent}; use std::io::BufRead; +use std::path::Path; #[derive(Debug, Clone)] pub struct AgentStreamRequest { pub message: String, pub system_prompt: Option, pub vault_path: String, + pub permission_mode: AiAgentPermissionMode, } pub fn check_cli() -> AiAgentAvailability { crate::opencode_discovery::check_cli() } -pub fn run_agent_stream(request: AgentStreamRequest, mut emit: F) -> Result +pub fn run_agent_stream(request: AgentStreamRequest, emit: F) -> Result where F: FnMut(AiAgentStreamEvent), { let binary = crate::opencode_discovery::find_binary()?; + run_agent_stream_with_binary(&binary, request, emit) +} + +fn run_agent_stream_with_binary( + binary: &Path, + request: AgentStreamRequest, + mut emit: F, +) -> Result +where + F: FnMut(AiAgentStreamEvent), +{ let mut command = crate::opencode_config::build_command(&binary, &request)?; let mut child = command .spawn() @@ -56,3 +69,88 @@ where emit(AiAgentStreamEvent::Done); Ok(session_id) } + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(unix)] + fn executable_script(dir: &Path, body: &str) -> std::path::PathBuf { + use std::os::unix::fs::PermissionsExt; + + let script = dir.join("opencode"); + std::fs::write(&script, format!("#!/bin/sh\n{body}")).unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + script + } + + fn request(vault_path: String) -> AgentStreamRequest { + AgentStreamRequest { + message: "Summarize".into(), + system_prompt: None, + vault_path, + permission_mode: AiAgentPermissionMode::Safe, + } + } + + #[cfg(unix)] + #[test] + fn run_agent_stream_maps_opencode_json_events() { + let dir = tempfile::tempdir().unwrap(); + let vault = tempfile::tempdir().unwrap(); + let binary = executable_script( + dir.path(), + r#"printf '%s\n' '{"type":"session","sessionID":"open_1"}' +printf '%s\n' '{"type":"message","text":"Done"}' +"#, + ); + + let mut events = Vec::new(); + let session_id = run_agent_stream_with_binary( + &binary, + request(vault.path().to_string_lossy().into_owned()), + |event| events.push(event), + ) + .unwrap(); + + assert_eq!(session_id, "open_1"); + assert!(matches!( + &events[0], + AiAgentStreamEvent::Init { session_id } if session_id == "open_1" + )); + assert!(matches!( + &events[1], + AiAgentStreamEvent::TextDelta { text } if text == "Done" + )); + assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done))); + } + + #[cfg(unix)] + #[test] + fn run_agent_stream_reports_opencode_nonzero_exit_errors() { + let dir = tempfile::tempdir().unwrap(); + let vault = tempfile::tempdir().unwrap(); + let binary = executable_script( + dir.path(), + r#"printf '%s\n' '{"type":"session","sessionID":"open_1"}' +printf '%s\n' 'provider login required' >&2 +exit 3 +"#, + ); + + let mut events = Vec::new(); + let session_id = run_agent_stream_with_binary( + &binary, + request(vault.path().to_string_lossy().into_owned()), + |event| events.push(event), + ) + .unwrap(); + + assert_eq!(session_id, "open_1"); + assert!(events.iter().any(|event| matches!( + event, + AiAgentStreamEvent::Error { message } if message.contains("provider configured") + ))); + assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done))); + } +} diff --git a/src-tauri/src/opencode_config.rs b/src-tauri/src/opencode_config.rs index 096fc1c7..af220d47 100644 --- a/src-tauri/src/opencode_config.rs +++ b/src-tauri/src/opencode_config.rs @@ -1,3 +1,4 @@ +use crate::ai_agents::AiAgentPermissionMode; use crate::opencode_cli::AgentStreamRequest; use std::path::Path; use std::process::Stdio; @@ -12,7 +13,7 @@ pub(crate) fn build_command( .arg(build_prompt(request)) .env( "OPENCODE_CONFIG_CONTENT", - build_config(&request.vault_path)?, + build_config(&request.vault_path, request.permission_mode)?, ) .current_dir(&request.vault_path) .stdin(Stdio::null()) @@ -40,7 +41,10 @@ fn build_prompt(request: &AgentStreamRequest) -> String { } } -fn build_config(vault_path: &str) -> Result { +fn build_config( + vault_path: &str, + permission_mode: AiAgentPermissionMode, +) -> Result { let mcp_server = crate::mcp::mcp_server_dir()?.join("index.js"); let mcp_server_path = mcp_server .to_str() @@ -49,7 +53,7 @@ fn build_config(vault_path: &str) -> Result { serde_json::to_string(&serde_json::json!({ "$schema": "https://opencode.ai/config.json", - "permission": permission_config(), + "permission": permission_config(permission_mode), "mcp": { "tolaria": { "type": "local", @@ -62,7 +66,12 @@ fn build_config(vault_path: &str) -> Result { .map_err(|error| format!("Failed to serialize opencode config: {error}")) } -fn permission_config() -> serde_json::Value { +fn permission_config(permission_mode: AiAgentPermissionMode) -> serde_json::Value { + let bash_permission = match permission_mode { + AiAgentPermissionMode::Safe => "deny", + AiAgentPermissionMode::PowerUser => "allow", + }; + serde_json::json!({ "read": "allow", "edit": "allow", @@ -70,7 +79,7 @@ fn permission_config() -> serde_json::Value { "grep": "allow", "list": "allow", "external_directory": "deny", - "bash": "deny" + "bash": bash_permission }) } @@ -85,6 +94,7 @@ mod tests { message: "Rename the note".into(), system_prompt: None, vault_path: "/tmp/vault".into(), + permission_mode: crate::ai_agents::AiAgentPermissionMode::Safe, } } @@ -118,7 +128,9 @@ mod tests { #[test] fn config_includes_permissions_and_tolaria_mcp_server() { - if let Ok(config) = build_config("/tmp/vault") { + if let Ok(config) = + build_config("/tmp/vault", crate::ai_agents::AiAgentPermissionMode::Safe) + { let json: serde_json::Value = serde_json::from_str(&config).unwrap(); assert_eq!(json["permission"]["edit"], "allow"); assert_eq!(json["permission"]["external_directory"], "deny"); @@ -136,6 +148,18 @@ mod tests { } } + #[test] + fn power_user_config_allows_bash_but_keeps_external_directories_denied() { + if let Ok(config) = build_config( + "/tmp/vault", + crate::ai_agents::AiAgentPermissionMode::PowerUser, + ) { + let json: serde_json::Value = serde_json::from_str(&config).unwrap(); + assert_eq!(json["permission"]["bash"], "allow"); + assert_eq!(json["permission"]["external_directory"], "deny"); + } + } + #[test] fn prompt_keeps_system_prompt_first() { let prompt = build_prompt(&AgentStreamRequest { diff --git a/src-tauri/src/opencode_events_tests.rs b/src-tauri/src/opencode_events_tests.rs index 4fd0c632..520bad67 100644 --- a/src-tauri/src/opencode_events_tests.rs +++ b/src-tauri/src/opencode_events_tests.rs @@ -1,5 +1,65 @@ use super::*; +fn dispatch_events(events: impl IntoIterator) -> Vec { + let mut mapped = Vec::new(); + + for event in events { + dispatch_event(&event, &mut |event| mapped.push(event)); + } + + mapped +} + +struct ToolExpectation<'a> { + tool_id: &'a str, + tool_name: &'a str, + input: Option<&'a str>, + output: Option<&'a str>, +} + +fn assert_tool_pair(events: &[AiAgentStreamEvent], expected: ToolExpectation<'_>) { + assert!(matches!( + &events[0], + AiAgentStreamEvent::ToolStart { + tool_name: actual_name, + tool_id: actual_id, + input: actual_input, + } if actual_name == expected.tool_name + && actual_id == expected.tool_id + && actual_input.as_deref() == expected.input + )); + assert!(matches!( + &events[1], + AiAgentStreamEvent::ToolDone { + tool_id: actual_id, + output: actual_output, + } if actual_id == expected.tool_id && actual_output.as_deref() == expected.output + )); +} + +#[test] +fn parse_line_reports_read_errors_and_skips_blank_or_invalid_lines() { + let mut events = Vec::new(); + + let read_error = parse_line( + Err(std::io::Error::new( + std::io::ErrorKind::Other, + "broken pipe", + )), + &mut |event| events.push(event), + ); + let blank = parse_line(Ok(" ".into()), &mut |event| events.push(event)); + let invalid = parse_line(Ok("not json".into()), &mut |event| events.push(event)); + + assert!(read_error.is_none()); + assert!(blank.is_none()); + assert!(invalid.is_none()); + assert!(matches!( + &events[0], + AiAgentStreamEvent::Error { message } if message.contains("broken pipe") + )); +} + #[test] fn dispatch_maps_session_reasoning_and_text() { let mut events = Vec::new(); @@ -53,61 +113,63 @@ fn dispatch_maps_part_backed_reasoning_and_text() { #[test] fn dispatch_maps_tool_events() { - let mut events = Vec::new(); - let tool_start = serde_json::json!({ - "type": "tool_use", - "id": "tool_1", - "name": "read", - "input": { "path": "Note.md" } - }); - let tool_done = serde_json::json!({ "type": "tool_result", "id": "tool_1", "output": "ok" }); + let direct = dispatch_events([ + serde_json::json!({ + "type": "tool_use", + "id": "tool_1", + "name": "read", + "input": { "path": "Note.md" } + }), + serde_json::json!({ "type": "tool_result", "id": "tool_1", "output": "ok" }), + ]); + let part_backed = dispatch_events([ + serde_json::json!({ + "type": "tool_use", + "part": { + "id": "prt_tool_1", + "tool": "read", + "input": { "path": "Note.md" } + } + }), + serde_json::json!({ + "type": "tool_result", + "part": { + "id": "prt_tool_1", + "output": "ok" + } + }), + ]); - dispatch_event(&tool_start, &mut |event| events.push(event)); - dispatch_event(&tool_done, &mut |event| events.push(event)); - - assert!(matches!( - &events[0], - AiAgentStreamEvent::ToolStart { tool_name, tool_id, input } - if tool_name == "read" && tool_id == "tool_1" && input.as_deref() == Some(r#"{"path":"Note.md"}"#) - )); - assert!(matches!( - &events[1], - AiAgentStreamEvent::ToolDone { tool_id, output } - if tool_id == "tool_1" && output.as_deref() == Some("ok") - )); + assert_tool_pair( + &direct, + ToolExpectation { + tool_id: "tool_1", + tool_name: "read", + input: Some(r#"{"path":"Note.md"}"#), + output: Some("ok"), + }, + ); + assert_tool_pair( + &part_backed, + ToolExpectation { + tool_id: "prt_tool_1", + tool_name: "read", + input: Some(r#"{"path":"Note.md"}"#), + output: Some("ok"), + }, + ); } #[test] -fn dispatch_maps_part_backed_tool_events() { +fn dispatch_maps_error_events() { let mut events = Vec::new(); - let tool_start = serde_json::json!({ - "type": "tool_use", - "part": { - "id": "prt_tool_1", - "tool": "read", - "input": { "path": "Note.md" } - } - }); - let tool_done = serde_json::json!({ - "type": "tool_result", - "part": { - "id": "prt_tool_1", - "output": "ok" - } - }); + let error = serde_json::json!({ "type": "error", "message": "provider failed" }); - dispatch_event(&tool_start, &mut |event| events.push(event)); - dispatch_event(&tool_done, &mut |event| events.push(event)); + dispatch_event(&error, &mut |event| events.push(event)); assert!(matches!( &events[0], - AiAgentStreamEvent::ToolStart { tool_name, tool_id, input } - if tool_name == "read" && tool_id == "prt_tool_1" && input.as_deref() == Some(r#"{"path":"Note.md"}"#) - )); - assert!(matches!( - &events[1], - AiAgentStreamEvent::ToolDone { tool_id, output } - if tool_id == "prt_tool_1" && output.as_deref() == Some("ok") + AiAgentStreamEvent::Error { message } if message == "provider failed" )); } @@ -121,3 +183,12 @@ fn format_error_explains_missing_auth_or_provider_setup() { assert!(message.contains("OpenCode CLI is not authenticated")); assert!(message.contains("opencode auth login")); } + +#[test] +fn format_error_uses_status_or_first_stderr_lines() { + let empty = format_error(String::new(), "exit status: 2".into()); + let truncated = format_error("line 1\nline 2\nline 3\nline 4".into(), "ignored".into()); + + assert_eq!(empty, "opencode exited with status exit status: 2"); + assert_eq!(truncated, "line 1\nline 2\nline 3"); +} diff --git a/src-tauri/src/pi_cli.rs b/src-tauri/src/pi_cli.rs index 776008dc..9f13df13 100644 --- a/src-tauri/src/pi_cli.rs +++ b/src-tauri/src/pi_cli.rs @@ -1,22 +1,35 @@ -use crate::ai_agents::{AiAgentAvailability, AiAgentStreamEvent}; +use crate::ai_agents::{AiAgentAvailability, AiAgentPermissionMode, AiAgentStreamEvent}; use std::io::BufRead; +use std::path::Path; #[derive(Debug, Clone)] pub struct AgentStreamRequest { pub message: String, pub system_prompt: Option, pub vault_path: String, + pub permission_mode: AiAgentPermissionMode, } pub fn check_cli() -> AiAgentAvailability { crate::pi_discovery::check_cli() } -pub fn run_agent_stream(request: AgentStreamRequest, mut emit: F) -> Result +pub fn run_agent_stream(request: AgentStreamRequest, emit: F) -> Result where F: FnMut(AiAgentStreamEvent), { let binary = crate::pi_discovery::find_binary()?; + run_agent_stream_with_binary(&binary, request, emit) +} + +fn run_agent_stream_with_binary( + binary: &Path, + request: AgentStreamRequest, + mut emit: F, +) -> Result +where + F: FnMut(AiAgentStreamEvent), +{ let agent_dir = tempfile::Builder::new() .prefix("tolaria-pi-agent-") .tempdir() @@ -60,3 +73,88 @@ where emit(AiAgentStreamEvent::Done); Ok(session_id) } + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(unix)] + fn executable_script(dir: &Path, body: &str) -> std::path::PathBuf { + use std::os::unix::fs::PermissionsExt; + + let script = dir.join("pi"); + std::fs::write(&script, format!("#!/bin/sh\n{body}")).unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + script + } + + fn request(vault_path: String) -> AgentStreamRequest { + AgentStreamRequest { + message: "Summarize".into(), + system_prompt: None, + vault_path, + permission_mode: AiAgentPermissionMode::Safe, + } + } + + #[cfg(unix)] + #[test] + fn run_agent_stream_maps_pi_json_events() { + let dir = tempfile::tempdir().unwrap(); + let vault = tempfile::tempdir().unwrap(); + let binary = executable_script( + dir.path(), + r#"printf '%s\n' '{"type":"session","id":"pi_1"}' +printf '%s\n' '{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"Done"}}' +"#, + ); + + let mut events = Vec::new(); + let session_id = run_agent_stream_with_binary( + &binary, + request(vault.path().to_string_lossy().into_owned()), + |event| events.push(event), + ) + .unwrap(); + + assert_eq!(session_id, "pi_1"); + assert!(matches!( + &events[0], + AiAgentStreamEvent::Init { session_id } if session_id == "pi_1" + )); + assert!(matches!( + &events[1], + AiAgentStreamEvent::TextDelta { text } if text == "Done" + )); + assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done))); + } + + #[cfg(unix)] + #[test] + fn run_agent_stream_reports_pi_nonzero_exit_errors() { + let dir = tempfile::tempdir().unwrap(); + let vault = tempfile::tempdir().unwrap(); + let binary = executable_script( + dir.path(), + r#"printf '%s\n' '{"type":"session","id":"pi_1"}' +printf '%s\n' 'api key login required' >&2 +exit 4 +"#, + ); + + let mut events = Vec::new(); + let session_id = run_agent_stream_with_binary( + &binary, + request(vault.path().to_string_lossy().into_owned()), + |event| events.push(event), + ) + .unwrap(); + + assert_eq!(session_id, "pi_1"); + assert!(events.iter().any(|event| matches!( + event, + AiAgentStreamEvent::Error { message } if message.contains("not authenticated") + ))); + assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done))); + } +} diff --git a/src-tauri/src/pi_config.rs b/src-tauri/src/pi_config.rs index fc33c34e..9da57ac8 100644 --- a/src-tauri/src/pi_config.rs +++ b/src-tauri/src/pi_config.rs @@ -1,3 +1,4 @@ +use crate::ai_agents::AiAgentPermissionMode; use crate::pi_cli::AgentStreamRequest; use std::path::Path; use std::process::Stdio; @@ -7,7 +8,7 @@ pub(crate) fn build_command( request: &AgentStreamRequest, agent_dir: &Path, ) -> Result { - write_mcp_config(agent_dir, &request.vault_path)?; + write_mcp_config(agent_dir, &request.vault_path, request.permission_mode)?; let mut command = crate::hidden_command(binary); command @@ -46,15 +47,22 @@ fn build_prompt(request: &AgentStreamRequest) -> String { } } -fn write_mcp_config(agent_dir: &Path, vault_path: &str) -> Result<(), String> { +fn write_mcp_config( + agent_dir: &Path, + vault_path: &str, + permission_mode: AiAgentPermissionMode, +) -> Result<(), String> { std::fs::create_dir_all(agent_dir) .map_err(|error| format!("Failed to create Pi agent directory: {error}"))?; - let config = build_mcp_config(vault_path)?; + let config = build_mcp_config(vault_path, permission_mode)?; std::fs::write(agent_dir.join("mcp.json"), config) .map_err(|error| format!("Failed to write Pi MCP config: {error}")) } -fn build_mcp_config(vault_path: &str) -> Result { +fn build_mcp_config( + vault_path: &str, + _permission_mode: AiAgentPermissionMode, +) -> Result { let mcp_server = crate::mcp::mcp_server_dir()?.join("index.js"); let mcp_server_path = mcp_server .to_str() @@ -93,6 +101,7 @@ mod tests { message: "Rename the note".into(), system_prompt: None, vault_path: "/tmp/vault".into(), + permission_mode: crate::ai_agents::AiAgentPermissionMode::Safe, } } @@ -129,7 +138,9 @@ mod tests { #[test] fn mcp_config_includes_tolaria_server_for_active_vault() { - if let Ok(config) = build_mcp_config("/tmp/vault") { + if let Ok(config) = + build_mcp_config("/tmp/vault", crate::ai_agents::AiAgentPermissionMode::Safe) + { let json: serde_json::Value = serde_json::from_str(&config).unwrap(); assert_eq!(json["settings"]["toolPrefix"], "none"); assert_eq!(json["mcpServers"]["tolaria"]["command"], "node"); @@ -147,6 +158,19 @@ mod tests { } } + #[test] + fn power_user_mode_uses_the_same_pi_mcp_config_as_safe_mode() { + let safe = + build_mcp_config("/tmp/vault", crate::ai_agents::AiAgentPermissionMode::Safe).unwrap(); + let power = build_mcp_config( + "/tmp/vault", + crate::ai_agents::AiAgentPermissionMode::PowerUser, + ) + .unwrap(); + + assert_eq!(safe, power); + } + #[test] fn prompt_keeps_system_prompt_first() { let prompt = build_prompt(&AgentStreamRequest { diff --git a/src-tauri/src/pi_events_tests.rs b/src-tauri/src/pi_events_tests.rs index 624e1ca3..6a12fc56 100644 --- a/src-tauri/src/pi_events_tests.rs +++ b/src-tauri/src/pi_events_tests.rs @@ -1,5 +1,28 @@ use super::*; +#[test] +fn parse_line_reports_read_errors_and_skips_blank_or_invalid_lines() { + let mut events = Vec::new(); + + let read_error = parse_line( + Err(std::io::Error::new( + std::io::ErrorKind::Other, + "broken pipe", + )), + &mut |event| events.push(event), + ); + let blank = parse_line(Ok(" ".into()), &mut |event| events.push(event)); + let invalid = parse_line(Ok("not json".into()), &mut |event| events.push(event)); + + assert!(read_error.is_none()); + assert!(blank.is_none()); + assert!(invalid.is_none()); + assert!(matches!( + &events[0], + AiAgentStreamEvent::Error { message } if message.contains("broken pipe") + )); +} + #[test] fn dispatch_maps_session_thinking_and_text() { let mut events = Vec::new(); @@ -62,6 +85,19 @@ fn dispatch_maps_tool_events() { )); } +#[test] +fn dispatch_maps_error_events() { + let mut events = Vec::new(); + let error = serde_json::json!({ "type": "error", "message": "provider failed" }); + + dispatch_event(&error, &mut |event| events.push(event)); + + assert!(matches!( + &events[0], + AiAgentStreamEvent::Error { message } if message == "provider failed" + )); +} + #[test] fn format_error_explains_missing_auth_or_provider_setup() { let message = format_error( @@ -72,3 +108,12 @@ fn format_error_explains_missing_auth_or_provider_setup() { assert!(message.contains("Pi CLI is not authenticated")); assert!(message.contains("pi /login")); } + +#[test] +fn format_error_uses_status_or_first_stderr_lines() { + let empty = format_error(String::new(), "exit status: 2".into()); + let truncated = format_error("line 1\nline 2\nline 3\nline 4".into(), "ignored".into()); + + assert_eq!(empty, "pi exited with status exit status: 2"); + assert_eq!(truncated, "line 1\nline 2\nline 3"); +} diff --git a/src/components/AiMessage.tsx b/src/components/AiMessage.tsx index d10ce560..f186e730 100644 --- a/src/components/AiMessage.tsx +++ b/src/components/AiMessage.tsx @@ -18,6 +18,7 @@ export interface AiAction { export interface AiMessageProps { userMessage: string references?: NoteReference[] + localMarker?: string reasoning?: string reasoningDone?: boolean actions: AiAction[] @@ -27,6 +28,18 @@ export interface AiMessageProps { onNavigateWikilink?: (target: string) => void } +function LocalMarker({ text }: { text: string }) { + return ( +
+ {text} +
+ ) +} + function ReferencePill({ reference, onClick }: { reference: NoteReference onClick?: (path: string) => void @@ -176,7 +189,15 @@ function StreamingIndicator() { ) } -export function AiMessage({ userMessage, references, reasoning, reasoningDone, actions, response, isStreaming, onOpenNote, onNavigateWikilink }: AiMessageProps) { +export function AiMessage(props: AiMessageProps) { + if (props.localMarker) { + return + } + + return +} + +function ConversationMessage({ userMessage, references, reasoning, reasoningDone, actions, response, isStreaming, onOpenNote, onNavigateWikilink }: AiMessageProps) { // Manual override: null = follow auto behavior, true/false = user forced const [userOverride, setUserOverride] = useState(false) const [expandedActions, setExpandedActions] = useState>(new Set()) diff --git a/src/components/AiPanel.test.tsx b/src/components/AiPanel.test.tsx index a236b1a3..6da94f03 100644 --- a/src/components/AiPanel.test.tsx +++ b/src/components/AiPanel.test.tsx @@ -4,20 +4,27 @@ import { AiPanel } from './AiPanel' import { UNSUPPORTED_INLINE_PASTE_MESSAGE } from './InlineWikilinkInput' import type { VaultEntry } from '../types' import { queueAiPrompt } from '../utils/aiPromptBridge' +import { bindVaultConfigStore, getVaultConfig, resetVaultConfigStore } from '../utils/vaultConfigStore' // Mock the hooks and utils to isolate component tests let mockMessages: ReturnType['messages'] = [] let mockStatus: ReturnType['status'] = 'idle' const mockSendMessage = vi.fn() const mockClearConversation = vi.fn() +const mockAddLocalMarker = vi.fn() +const mockUseCliAiAgent = vi.fn() vi.mock('../hooks/useCliAiAgent', () => ({ - useCliAiAgent: () => ({ - messages: mockMessages, - status: mockStatus, - sendMessage: mockSendMessage, - clearConversation: mockClearConversation, - }), + useCliAiAgent: (...args: unknown[]) => { + mockUseCliAiAgent(...args) + return { + messages: mockMessages, + status: mockStatus, + sendMessage: mockSendMessage, + clearConversation: mockClearConversation, + addLocalMarker: mockAddLocalMarker, + } + }, })) vi.mock('../utils/ai-chat', () => ({ @@ -45,7 +52,18 @@ const makeEntry = (overrides: Partial = {}): VaultEntry => ({ icon: null, color: null, order: null, + sidebarLabel: null, + template: null, + sort: null, + view: null, + visible: null, + organized: false, + favorite: false, + favoriteIndex: null, + listPropertiesDisplay: [], outgoingLinks: [], + properties: {}, + hasH1: false, ...overrides, }) @@ -55,12 +73,78 @@ describe('AiPanel', () => { mockStatus = 'idle' mockSendMessage.mockReset() mockClearConversation.mockReset() + mockAddLocalMarker.mockReset() + mockUseCliAiAgent.mockReset() + resetVaultConfigStore() + bindVaultConfigStore({ + zoom: null, + view_mode: null, + editor_mode: null, + note_layout: null, + tag_colors: null, + status_colors: null, + property_display_modes: null, + inbox: null, + allNotes: null, + ai_agent_permission_mode: 'safe', + }, vi.fn()) }) it('renders panel with the default CLI agent header', () => { render() expect(screen.getByText('AI Agent')).toBeTruthy() - expect(screen.getByText('Claude Code')).toBeTruthy() + expect(screen.getByText('Claude Code · Safe')).toBeTruthy() + }) + + it('passes the vault permission mode to the AI agent session', () => { + bindVaultConfigStore({ + ...getVaultConfig(), + ai_agent_permission_mode: 'power_user', + }, vi.fn()) + + render() + + expect(screen.getByText('Claude Code · Power User')).toBeTruthy() + expect(mockUseCliAiAgent).toHaveBeenCalledWith( + '/tmp/vault', + undefined, + expect.any(Object), + expect.objectContaining({ permissionMode: 'power_user' }), + ) + }) + + it('persists permission mode changes and records a local transcript marker', () => { + const save = vi.fn() + bindVaultConfigStore({ + ...getVaultConfig(), + ai_agent_permission_mode: 'safe', + }, save) + mockMessages = [{ + userMessage: 'Existing question', + actions: [], + response: 'Existing answer.', + id: 'msg-existing', + }] + + render() + fireEvent.click(screen.getByRole('button', { name: 'Power User' })) + + expect(getVaultConfig().ai_agent_permission_mode).toBe('power_user') + expect(save).toHaveBeenLastCalledWith(expect.objectContaining({ + ai_agent_permission_mode: 'power_user', + })) + expect(mockAddLocalMarker).toHaveBeenCalledWith( + 'AI permission mode changed to Power User. It will apply to the next message.', + ) + }) + + it('disables permission mode changes while the AI agent is running', () => { + mockStatus = 'thinking' + + render() + + expect(screen.getByRole('button', { name: 'Vault Safe' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'Power User' })).toBeDisabled() }) it('renders data-testid ai-panel', () => { diff --git a/src/components/AiPanel.tsx b/src/components/AiPanel.tsx index 072ff439..73c34736 100644 --- a/src/components/AiPanel.tsx +++ b/src/components/AiPanel.tsx @@ -82,8 +82,10 @@ export function AiPanelView({ linkedEntries, hasContext, isActive, + permissionMode, handleSend, handleNavigateWikilink, + handlePermissionModeChange, handleNewChat, } = controller @@ -115,6 +117,9 @@ export function AiPanelView({ void onClose: () => void onCopyMcpConfig?: () => void onNewChat: () => void @@ -124,59 +131,108 @@ function AiPanelEmptyState({ export function AiPanelHeader({ agentLabel, agentReadiness, + permissionMode, + permissionModeDisabled, + onPermissionModeChange, onClose, onCopyMcpConfig, onNewChat, }: AiPanelHeaderProps) { + const modeLabel = AI_AGENT_PERMISSION_MODE_LABELS[permissionMode].short + return (
- -
- - AI Agent - - - {agentReadiness === 'checking' - ? 'Checking availability' - : `${agentLabel}${agentReadiness === 'missing' ? ' · not installed' : ''}`} - -
- {onCopyMcpConfig ? ( +
+ +
+ + AI Agent + + + {agentReadiness === 'checking' + ? 'Checking availability' + : `${agentLabel}${agentReadiness === 'missing' ? ' · not installed' : ` · ${modeLabel}`}`} + +
+ {onCopyMcpConfig ? ( + + ) : null} - ) : null} - - + +
+ +
+ ) +} + +function AiPermissionModeToggle({ + value, + disabled, + onChange, +}: { + value: AiAgentPermissionMode + disabled: boolean + onChange: (mode: AiAgentPermissionMode) => void +}) { + return ( +
+ {(['safe', 'power_user'] as const).map((mode) => { + const selected = value === mode + return ( + + ) + })}
) } @@ -275,16 +331,20 @@ export function AiPanelComposer({ inputRef={inputRef} /> - + ) diff --git a/src/components/useAiPanelController.ts b/src/components/useAiPanelController.ts index 2a842417..e09f984a 100644 --- a/src/components/useAiPanelController.ts +++ b/src/components/useAiPanelController.ts @@ -1,7 +1,17 @@ -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useMemo, useState, useSyncExternalStore } from 'react' import type { AiAgentId, AiAgentReadiness } from '../lib/aiAgents' +import { + aiAgentPermissionModeMarker, + normalizeAiAgentPermissionMode, + type AiAgentPermissionMode, +} from '../lib/aiAgentPermissionMode' import { useCliAiAgent, type AgentFileCallbacks } from '../hooks/useCliAiAgent' import type { VaultEntry } from '../types' +import { + getVaultConfig, + subscribeVaultConfig, + updateVaultConfigField, +} from '../utils/vaultConfigStore' import { type NoteListItem, type NoteReference, @@ -32,8 +42,10 @@ export interface AiPanelController { linkedEntries: ReturnType['linkedEntries'] hasContext: boolean isActive: boolean + permissionMode: AiAgentPermissionMode handleSend: (text: string, references: NoteReference[]) => void handleNavigateWikilink: (target: string) => void + handlePermissionModeChange: (mode: AiAgentPermissionMode) => void handleNewChat: () => void } @@ -44,6 +56,26 @@ function resolveAgentReady( return (readiness ?? (ready ? 'ready' : 'missing')) === 'ready' } +function useVaultAiAgentPermissionMode(): AiAgentPermissionMode { + const vaultConfig = useSyncExternalStore(subscribeVaultConfig, getVaultConfig) + return normalizeAiAgentPermissionMode(vaultConfig.ai_agent_permission_mode) +} + +function useAgentFileCallbacks({ + onFileCreated, + onFileModified, + onVaultChanged, +}: Pick< + UseAiPanelControllerArgs, + 'onFileCreated' | 'onFileModified' | 'onVaultChanged' +>): AgentFileCallbacks { + return useMemo(() => ({ + onFileCreated, + onFileModified, + onVaultChanged, + }), [onFileCreated, onFileModified, onVaultChanged]) +} + export function useAiPanelController({ vaultPath, defaultAiAgent, @@ -71,15 +103,13 @@ export function useAiPanelController({ noteListFilter, }) - const fileCallbacks = useMemo(() => ({ - onFileCreated, - onFileModified, - onVaultChanged, - }), [onFileCreated, onFileModified, onVaultChanged]) + const fileCallbacks = useAgentFileCallbacks({ onFileCreated, onFileModified, onVaultChanged }) + const permissionMode = useVaultAiAgentPermissionMode() const agent = useCliAiAgent(vaultPath, contextPrompt, fileCallbacks, { agent: defaultAiAgent, agentReady: resolveAgentReady(defaultAiAgentReadiness, defaultAiAgentReady), + permissionMode, }) const hasContext = !!activeEntry const isActive = agent.status === 'thinking' || agent.status === 'tool-executing' @@ -94,6 +124,14 @@ export function useAiPanelController({ onOpenNote?.(target) }, [onOpenNote]) + const handlePermissionModeChange = useCallback((mode: AiAgentPermissionMode) => { + const nextMode = normalizeAiAgentPermissionMode(mode) + if (isActive || nextMode === permissionMode) return + + updateVaultConfigField('ai_agent_permission_mode', nextMode) + agent.addLocalMarker(aiAgentPermissionModeMarker(nextMode)) + }, [agent, isActive, permissionMode]) + const handleNewChat = useCallback(() => { agent.clearConversation() setInput('') @@ -106,8 +144,10 @@ export function useAiPanelController({ linkedEntries, hasContext, isActive, + permissionMode, handleSend, handleNavigateWikilink, + handlePermissionModeChange, handleNewChat, } } diff --git a/src/hooks/useCliAiAgent.test.ts b/src/hooks/useCliAiAgent.test.ts index 9faa7e51..a494e9ac 100644 --- a/src/hooks/useCliAiAgent.test.ts +++ b/src/hooks/useCliAiAgent.test.ts @@ -14,11 +14,15 @@ vi.mock('../utils/ai-agent', () => ({ const mockStreamAiAgent = vi.mocked(streamAiAgent) const VAULT = '/Users/luca/Laputa' -function renderAgent(contextPrompt: string | undefined = undefined) { +function renderAgent( + contextPrompt: string | undefined = undefined, + permissionMode: 'safe' | 'power_user' = 'safe', +) { return renderHook( ({ context }) => useCliAiAgent(VAULT, context, undefined, { agent: 'codex', agentReady: true, + permissionMode, }), { initialProps: { context: contextPrompt } }, ) @@ -49,6 +53,37 @@ describe('useCliAiAgent', () => { })) }) + it('forwards the current permission mode to the stream request', async () => { + const { result } = renderAgent(undefined, 'power_user') + + await act(async () => { + await result.current.sendMessage('Use the local tools') + }) + + expect(mockStreamAiAgent).toHaveBeenCalledWith(expect.objectContaining({ + permissionMode: 'power_user', + })) + }) + + it('adds local transcript markers without sending them as chat history', async () => { + const { result } = renderAgent() + + act(() => { + result.current.addLocalMarker('AI permission mode changed to Power User. It will apply to the next message.') + }) + + await act(async () => { + await result.current.sendMessage('Continue') + }) + + expect(result.current.messages[0]).toEqual(expect.objectContaining({ + localMarker: 'AI permission mode changed to Power User. It will apply to the next message.', + })) + expect(mockStreamAiAgent).toHaveBeenCalledWith(expect.objectContaining({ + message: 'Continue', + })) + }) + it('embeds completed conversation history and clears it for a fresh chat', async () => { let responseNumber = 0 mockStreamAiAgent.mockImplementation(async ({ callbacks }) => { @@ -80,6 +115,7 @@ describe('useCliAiAgent', () => { const { result } = renderHook(() => useCliAiAgent(VAULT, undefined, undefined, { agent: 'codex', agentReady: false, + permissionMode: 'safe', })) await act(async () => { diff --git a/src/hooks/useCliAiAgent.ts b/src/hooks/useCliAiAgent.ts index c252be15..a26e40a3 100644 --- a/src/hooks/useCliAiAgent.ts +++ b/src/hooks/useCliAiAgent.ts @@ -1,5 +1,6 @@ import { useEffect, useRef, useState, type Dispatch, type MutableRefObject, type SetStateAction } from 'react' import type { AiAgentId } from '../lib/aiAgents' +import type { AiAgentPermissionMode } from '../lib/aiAgentPermissionMode' import type { NoteReference } from '../utils/ai-context' import { type AgentStatus, @@ -7,6 +8,7 @@ import { } from '../lib/aiAgentConversation' import type { AgentFileCallbacks } from '../lib/aiAgentFileOperations' import { + addAgentLocalMarker, clearAgentConversation, sendAgentMessage, type AiAgentSessionRuntime, @@ -20,6 +22,7 @@ export type { AiAgentMessage } from '../lib/aiAgentConversation' interface UseCliAiAgentOptions { agent: AiAgentId agentReady: boolean + permissionMode: AiAgentPermissionMode } interface UseCliAiAgentRuntime extends AiAgentSessionRuntime { @@ -66,6 +69,7 @@ export function useCliAiAgent( options: UseCliAiAgentOptions, ) { const { agent, agentReady } = options + const { permissionMode } = options const runtime = useCliAiAgentRuntime(fileCallbacks) const { messages, status } = runtime @@ -76,6 +80,7 @@ export function useCliAiAgent( agent, ready: agentReady, vaultPath, + permissionMode, systemPromptOverride: contextPrompt, }, prompt: { text, references }, @@ -86,5 +91,9 @@ export function useCliAiAgent( clearAgentConversation(runtime) } - return { messages, status, sendMessage, clearConversation } + function addLocalMarker(text: string): void { + addAgentLocalMarker(runtime, text) + } + + return { messages, status, sendMessage, clearConversation, addLocalMarker } } diff --git a/src/hooks/useVaultConfig.ts b/src/hooks/useVaultConfig.ts index d059bdc1..281bf924 100644 --- a/src/hooks/useVaultConfig.ts +++ b/src/hooks/useVaultConfig.ts @@ -11,6 +11,7 @@ import { subscribeVaultConfig, } from '../utils/vaultConfigStore' import { migrateLocalStorageToVaultConfig } from '../utils/configMigration' +import { DEFAULT_AI_AGENT_PERMISSION_MODE } from '../lib/aiAgentPermissionMode' const STORAGE_PREFIX = 'laputa:vault-config:' @@ -20,9 +21,10 @@ function storageKey(vaultPath: string): string { function loadFromStorage(vaultPath: string): VaultConfig { const DEFAULT: VaultConfig = { - zoom: null, view_mode: null, editor_mode: null, + zoom: null, view_mode: null, editor_mode: null, note_layout: null, + ai_agent_permission_mode: DEFAULT_AI_AGENT_PERMISSION_MODE, tag_colors: null, status_colors: null, property_display_modes: null, - inbox: null, + inbox: null, allNotes: null, } try { const raw = localStorage.getItem(storageKey(vaultPath)) diff --git a/src/lib/aiAgentConversation.ts b/src/lib/aiAgentConversation.ts index 1e656b2f..f12f2972 100644 --- a/src/lib/aiAgentConversation.ts +++ b/src/lib/aiAgentConversation.ts @@ -11,10 +11,12 @@ import { import type { NoteReference } from '../utils/ai-context' import type { AiAgentId } from './aiAgents' import { getAiAgentDefinition } from './aiAgents' +import type { AiAgentPermissionMode } from './aiAgentPermissionMode' export interface AiAgentMessage { userMessage: string references?: NoteReference[] + localMarker?: string reasoning?: string reasoningDone?: boolean actions: AiAction[] @@ -29,6 +31,7 @@ export interface AgentExecutionContext { agent: AiAgentId ready: boolean vaultPath: string + permissionMode: AiAgentPermissionMode systemPromptOverride?: string } @@ -38,7 +41,7 @@ export interface PendingUserPrompt { } function toChatHistory(messages: AiAgentMessage[]): ChatMessage[] { - return messages.flatMap((message) => { + return messages.filter((message) => !message.localMarker).flatMap((message) => { const history: ChatMessage[] = [{ role: 'user', content: message.userMessage, id: message.id ?? '' }] if (message.response) { history.push({ role: 'assistant', content: message.response, id: `${message.id}-resp` }) @@ -47,6 +50,21 @@ function toChatHistory(messages: AiAgentMessage[]): ChatMessage[] { }) } +export function appendLocalMarker( + setMessages: Dispatch>, + text: string, +): void { + setMessages((current) => [ + ...current, + { + userMessage: '', + localMarker: text, + actions: [], + id: nextMessageId(), + }, + ]) +} + export function createMissingAgentResponse(agent: AiAgentId): string { const definition = getAiAgentDefinition(agent) return `${definition.label} is not available on this machine. Install it or switch the default AI agent in Settings.` diff --git a/src/lib/aiAgentPermissionMode.test.ts b/src/lib/aiAgentPermissionMode.test.ts new file mode 100644 index 00000000..42be83ee --- /dev/null +++ b/src/lib/aiAgentPermissionMode.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { + AI_AGENT_PERMISSION_MODE_LABELS, + DEFAULT_AI_AGENT_PERMISSION_MODE, + aiAgentPermissionModeMarker, + normalizeAiAgentPermissionMode, +} from './aiAgentPermissionMode' + +describe('aiAgentPermissionMode', () => { + it('defaults missing, null, and unknown values to vault safe mode', () => { + expect(DEFAULT_AI_AGENT_PERMISSION_MODE).toBe('safe') + expect(normalizeAiAgentPermissionMode(undefined)).toBe('safe') + expect(normalizeAiAgentPermissionMode(null)).toBe('safe') + expect(normalizeAiAgentPermissionMode('danger')).toBe('safe') + }) + + it('preserves known permission modes and exposes compact labels', () => { + expect(normalizeAiAgentPermissionMode('safe')).toBe('safe') + expect(normalizeAiAgentPermissionMode('power_user')).toBe('power_user') + expect(AI_AGENT_PERMISSION_MODE_LABELS.safe.short).toBe('Safe') + expect(AI_AGENT_PERMISSION_MODE_LABELS.safe.control).toBe('Vault Safe') + expect(AI_AGENT_PERMISSION_MODE_LABELS.power_user.short).toBe('Power User') + }) + + it('formats a local transcript marker for mode changes', () => { + expect(aiAgentPermissionModeMarker('power_user')).toBe( + 'AI permission mode changed to Power User. It will apply to the next message.', + ) + }) +}) diff --git a/src/lib/aiAgentPermissionMode.ts b/src/lib/aiAgentPermissionMode.ts new file mode 100644 index 00000000..f8518e4f --- /dev/null +++ b/src/lib/aiAgentPermissionMode.ts @@ -0,0 +1,26 @@ +export type AiAgentPermissionMode = 'safe' | 'power_user' + +export const DEFAULT_AI_AGENT_PERMISSION_MODE: AiAgentPermissionMode = 'safe' + +export const AI_AGENT_PERMISSION_MODE_LABELS: Record< + AiAgentPermissionMode, + { short: string; control: string } +> = { + safe: { + short: 'Safe', + control: 'Vault Safe', + }, + power_user: { + short: 'Power User', + control: 'Power User', + }, +} + +export function normalizeAiAgentPermissionMode(value: unknown): AiAgentPermissionMode { + return value === 'power_user' ? 'power_user' : DEFAULT_AI_AGENT_PERMISSION_MODE +} + +export function aiAgentPermissionModeMarker(mode: AiAgentPermissionMode): string { + const label = AI_AGENT_PERMISSION_MODE_LABELS[mode].short + return `AI permission mode changed to ${label}. It will apply to the next message.` +} diff --git a/src/lib/aiAgentSession.ts b/src/lib/aiAgentSession.ts index a84de2e6..dbc0210d 100644 --- a/src/lib/aiAgentSession.ts +++ b/src/lib/aiAgentSession.ts @@ -1,6 +1,7 @@ import type { Dispatch, MutableRefObject, SetStateAction } from 'react' import { appendLocalResponse, + appendLocalMarker, appendStreamingMessage, buildFormattedMessage, createMissingAgentResponse, @@ -80,6 +81,7 @@ export async function sendAgentMessage({ message: formattedMessage, systemPrompt, vaultPath: context.vaultPath, + permissionMode: context.permissionMode, callbacks: createStreamCallbacks({ agent: context.agent, messageId, @@ -94,6 +96,13 @@ export async function sendAgentMessage({ }) } +export function addAgentLocalMarker( + runtime: Pick, + text: string, +): void { + appendLocalMarker(runtime.setMessages, text) +} + export function clearAgentConversation(runtime: Pick): void { runtime.abortRef.current.aborted = true runtime.responseAccRef.current = '' diff --git a/src/types.ts b/src/types.ts index 179df4e6..6651c26e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,5 @@ import type { AiAgentId } from './lib/aiAgents' +import type { AiAgentPermissionMode } from './lib/aiAgentPermissionMode' import type { ThemeMode } from './lib/themeMode' import type { AppLocale } from './lib/i18n' @@ -161,6 +162,7 @@ export interface VaultConfig { view_mode: string | null editor_mode: string | null note_layout?: NoteLayout | null + ai_agent_permission_mode?: AiAgentPermissionMode | null tag_colors: Record | null status_colors: Record | null property_display_modes: Record | null diff --git a/src/utils/streamAiAgent.test.ts b/src/utils/streamAiAgent.test.ts index 5d589f5e..0e23babc 100644 --- a/src/utils/streamAiAgent.test.ts +++ b/src/utils/streamAiAgent.test.ts @@ -104,6 +104,7 @@ describe('streamAiAgent', () => { message: 'Explain this', systemPrompt: 'SYSTEM', vaultPath: '/vault', + permissionMode: 'power_user', callbacks, }) @@ -116,6 +117,7 @@ describe('streamAiAgent', () => { message: 'Explain this', system_prompt: 'SYSTEM', vault_path: '/vault', + permission_mode: 'power_user', }, }) expect(callbacks.onThinking).toHaveBeenCalledWith('thinking...') diff --git a/src/utils/streamAiAgent.ts b/src/utils/streamAiAgent.ts index 749b6b18..e89c887c 100644 --- a/src/utils/streamAiAgent.ts +++ b/src/utils/streamAiAgent.ts @@ -1,5 +1,9 @@ import { isTauri } from '../mock-tauri' import { getAiAgentDefinition, type AiAgentId } from '../lib/aiAgents' +import { + normalizeAiAgentPermissionMode, + type AiAgentPermissionMode, +} from '../lib/aiAgentPermissionMode' type AiAgentStreamEvent = | { kind: 'Init'; session_id: string } @@ -24,6 +28,7 @@ export interface StreamAiAgentRequest { message: string systemPrompt?: string vaultPath: string + permissionMode?: AiAgentPermissionMode callbacks: AgentStreamCallbacks } @@ -70,6 +75,7 @@ export async function streamAiAgent( message, systemPrompt, vaultPath, + permissionMode, callbacks, } = request @@ -107,6 +113,7 @@ export async function streamAiAgent( message, system_prompt: systemPrompt || null, vault_path: vaultPath, + permission_mode: normalizeAiAgentPermissionMode(permissionMode), }, }) closeStream() diff --git a/src/utils/vaultConfigStore.test.ts b/src/utils/vaultConfigStore.test.ts new file mode 100644 index 00000000..1a0520c0 --- /dev/null +++ b/src/utils/vaultConfigStore.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { VaultConfig } from '../types' +import { + bindVaultConfigStore, + getVaultConfig, + resetVaultConfigStore, + updateVaultConfigField, +} from './vaultConfigStore' + +function vaultConfig(overrides: Partial = {}): VaultConfig { + return { + zoom: null, + view_mode: null, + editor_mode: null, + note_layout: null, + tag_colors: null, + status_colors: null, + property_display_modes: null, + inbox: null, + allNotes: null, + ...overrides, + } +} + +describe('vaultConfigStore', () => { + beforeEach(() => { + resetVaultConfigStore() + }) + + it('normalizes missing, null, and unknown AI agent permission modes to safe', () => { + bindVaultConfigStore(vaultConfig(), vi.fn()) + expect(getVaultConfig().ai_agent_permission_mode).toBe('safe') + + bindVaultConfigStore(vaultConfig({ ai_agent_permission_mode: null }), vi.fn()) + expect(getVaultConfig().ai_agent_permission_mode).toBe('safe') + + bindVaultConfigStore({ + ...vaultConfig(), + ai_agent_permission_mode: 'danger' as VaultConfig['ai_agent_permission_mode'], + }, vi.fn()) + expect(getVaultConfig().ai_agent_permission_mode).toBe('safe') + }) + + it('persists normalized AI agent permission mode updates', () => { + const save = vi.fn() + bindVaultConfigStore(vaultConfig(), save) + + updateVaultConfigField('ai_agent_permission_mode', 'power_user') + expect(getVaultConfig().ai_agent_permission_mode).toBe('power_user') + expect(save).toHaveBeenLastCalledWith(expect.objectContaining({ + ai_agent_permission_mode: 'power_user', + })) + + updateVaultConfigField('ai_agent_permission_mode', null) + expect(getVaultConfig().ai_agent_permission_mode).toBe('safe') + expect(save).toHaveBeenLastCalledWith(expect.objectContaining({ + ai_agent_permission_mode: 'safe', + })) + }) +}) diff --git a/src/utils/vaultConfigStore.ts b/src/utils/vaultConfigStore.ts index abf87879..5ba25f94 100644 --- a/src/utils/vaultConfigStore.ts +++ b/src/utils/vaultConfigStore.ts @@ -1,12 +1,17 @@ import type { VaultConfig } from '../types' +import { + DEFAULT_AI_AGENT_PERMISSION_MODE, + normalizeAiAgentPermissionMode, +} from '../lib/aiAgentPermissionMode' type SaveFn = (config: VaultConfig) => void type Listener = () => void const DEFAULT_CONFIG: VaultConfig = { zoom: null, view_mode: null, editor_mode: null, note_layout: null, + ai_agent_permission_mode: DEFAULT_AI_AGENT_PERMISSION_MODE, tag_colors: null, status_colors: null, property_display_modes: null, - inbox: null, + inbox: null, allNotes: null, } let config: VaultConfig = DEFAULT_CONFIG @@ -18,7 +23,7 @@ export function getVaultConfig(): VaultConfig { } export function bindVaultConfigStore(initial: VaultConfig, save: SaveFn): void { - config = initial + config = normalizeVaultConfig(initial) saveFn = save notify() } @@ -29,7 +34,7 @@ export function resetVaultConfigStore(): void { } export function updateVaultConfigField(key: K, value: VaultConfig[K]): void { - config = { ...config, [key]: value } + config = normalizeVaultConfig({ ...config, [key]: value }) saveFn?.(config) notify() } @@ -42,3 +47,11 @@ export function subscribeVaultConfig(listener: Listener): () => void { function notify(): void { for (const fn of listeners) fn() } + +function normalizeVaultConfig(next: VaultConfig): VaultConfig { + return { + ...DEFAULT_CONFIG, + ...next, + ai_agent_permission_mode: normalizeAiAgentPermissionMode(next.ai_agent_permission_mode), + } +} diff --git a/ui-design.pen b/ui-design.pen index 9d303d29..05cb2be0 100644 --- a/ui-design.pen +++ b/ui-design.pen @@ -14346,6 +14346,115 @@ ] } ] + }, + { + "type": "frame", + "id": "ai_agent_permission_mode_header", + "x": 560, + "y": 13980, + "name": "AI agent permission mode - panel header", + "theme": { + "Mode": "Light" + }, + "width": 360, + "height": 96, + "fill": "$--background", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "layout": "vertical", + "padding": 12, + "gap": 10, + "children": [ + { + "type": "frame", + "id": "ai_agent_permission_mode_title_row", + "width": "fill_container", + "height": 28, + "alignItems": "center", + "gap": 8, + "children": [ + { + "type": "text", + "id": "ai_agent_permission_mode_icon", + "fill": "$--muted-foreground", + "content": "robot", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600", + "letterSpacing": 0 + }, + { + "type": "frame", + "id": "ai_agent_permission_mode_title_stack", + "width": "fill_container", + "height": 28, + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "ai_agent_permission_mode_title", + "fill": "$--muted-foreground", + "content": "AI Agent", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "600", + "letterSpacing": 0 + }, + { + "type": "text", + "id": "ai_agent_permission_mode_agent", + "fill": "$--muted-foreground", + "content": "Claude Code · Safe", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "400", + "letterSpacing": 0 + } + ] + } + ] + }, + { + "type": "frame", + "id": "ai_agent_permission_mode_toggle", + "width": "fill_container", + "height": 28, + "fill": "$--muted", + "cornerRadius": 6, + "padding": 2, + "gap": 2, + "children": [ + { + "type": "text", + "id": "ai_agent_permission_mode_safe", + "width": "fill_container", + "height": 24, + "fill": "$--foreground", + "content": "Vault Safe", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600", + "letterSpacing": 0 + }, + { + "type": "text", + "id": "ai_agent_permission_mode_power", + "width": "fill_container", + "height": 24, + "fill": "$--muted-foreground", + "content": "Power User", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500", + "letterSpacing": 0 + } + ] + } + ] } ], "themes": {