diff --git a/bright_vision_core/headless_args.py b/bright_vision_core/headless_args.py index bac322f..6f65b2f 100644 --- a/bright_vision_core/headless_args.py +++ b/bright_vision_core/headless_args.py @@ -31,4 +31,9 @@ def default_headless_args(*, yes: bool = False) -> SimpleNamespace: cost_limit=float("inf"), disable_scraping=True, use_enhanced_map=False, + auto_save=False, + auto_load=False, + auto_save_session_name="auto-save", + session_encrypt=False, + session_key_file=None, ) diff --git a/bright_vision_core/headless_persistence.py b/bright_vision_core/headless_persistence.py new file mode 100644 index 0000000..0263ded --- /dev/null +++ b/bright_vision_core/headless_persistence.py @@ -0,0 +1,37 @@ +"""Map Vision / BrightVision session persistence options onto cecli headless args.""" + +from __future__ import annotations + +import os +from types import SimpleNamespace + + +def apply_persistence_to_args( + args: SimpleNamespace, + *, + session_encrypt: bool = False, + session_key_file: str | None = None, + auto_save: bool = False, + auto_load: bool = False, + auto_save_session_name: str = "auto-save", +) -> SimpleNamespace: + """Return a copy of *args* with persistence fields set (does not mutate *args*).""" + out = SimpleNamespace(**vars(args)) + out.session_encrypt = bool(session_encrypt) + out.session_key_file = session_key_file + out.auto_save = bool(auto_save) + out.auto_load = bool(auto_load) + out.auto_save_session_name = auto_save_session_name or "auto-save" + if out.session_encrypt and not session_crypto_key_available(session_key_file): + out.session_encrypt = False + return out + + +def session_crypto_key_available(session_key_file: str | None = None) -> bool: + from cecli import session_crypto + + return session_crypto.resolve_key(key_file=session_key_file) is not None + + +def persistence_env_active() -> bool: + return bool(os.environ.get("CECLI_SESSION_KEY", "").strip()) diff --git a/bright_vision_core/http_api.py b/bright_vision_core/http_api.py index 92713ec..71eb24c 100644 --- a/bright_vision_core/http_api.py +++ b/bright_vision_core/http_api.py @@ -121,6 +121,30 @@ class CreateSessionRequest(BaseModel): auto_commits: bool = True dirty_commits: bool = True dry_run: bool = False + session_encrypt: bool = Field( + False, + description="Encrypt saved sessions (requires CECLI_SESSION_KEY from OS keychain on desktop)", + ) + session_key_file: str | None = Field( + default=None, + description="Optional path to a session encryption key file", + ) + auto_save: bool = Field( + False, + description="Auto-save session JSON to .cecli/sessions/ (cecli --auto-save)", + ) + auto_load: bool = Field( + False, + description="Load auto-save session on session create (cecli --auto-load)", + ) + auto_save_session_name: str = Field( + "brightvision", + description="Basename for auto-save/load under .cecli/sessions/", + ) + chat_history_file: bool = Field( + True, + description="Append turns to .cecli/chat.history (cecli chat history file)", + ) class ConfirmRequest(BaseModel): @@ -357,6 +381,12 @@ def create_session(body: CreateSessionRequest): dirty_commits=body.dirty_commits, dry_run=body.dry_run, model_router=router_payload, + session_encrypt=body.session_encrypt, + session_key_file=body.session_key_file, + auto_save=body.auto_save, + auto_load=body.auto_load, + auto_save_session_name=body.auto_save_session_name, + chat_history_file=body.chat_history_file, ) except FileNotFoundError as err: raise HTTPException(status_code=404, detail=str(err)) from err @@ -667,7 +697,7 @@ def _wait_spec_job(job_id: str) -> GenerateTodoSpecResponse: @app.post("/workspaces/todos/{todo_id}/sync-spec-files", response_model=TodoItemModel) def sync_workspace_spec_files(workspace: str, todo_id: str): - """Import three-layer markdown from ``.aider-vision/specs/{id}/`` into todos.json.""" + """Import three-layer markdown from ``.brightvision/specs/{id}/`` into todos.json.""" api = _todos_for_workspace(workspace) try: item = api.import_spec_files(todo_id) @@ -800,6 +830,14 @@ def upload_session_files(session_id: str, body: UploadFilesRequest): ) +@app.post("/sessions/{session_id}/interrupt") +def interrupt_session_turn(session_id: str): + """Stop an in-flight turn (UI Stop); complements SSE disconnect.""" + session = _get_session(session_id) + session.interrupt_turn() + return {"ok": True} + + @app.post("/sessions/{session_id}/messages") def post_message(session_id: str, body: MessageRequest): session = _get_session(session_id) diff --git a/bright_vision_core/session.py b/bright_vision_core/session.py index 43b9a35..178e90f 100644 --- a/bright_vision_core/session.py +++ b/bright_vision_core/session.py @@ -6,7 +6,6 @@ import base64 import os -import shlex import threading import time from collections.abc import Callable @@ -16,11 +15,14 @@ _T = TypeVar("_T") # Wall-clock cap for slash/preproc (e.g. `/agent` on a local model looping on tools). -SLASH_PREPROC_TIMEOUT_S = float(os.environ.get("VISION_SLASH_PREPROC_TIMEOUT_S", "600")) +SLASH_PREPROC_TIMEOUT_S = float(os.environ.get("VISION_SLASH_PREPROC_TIMEOUT_S", "300")) from cecli import models from cecli.coders import Coder -from cecli.commands import Commands +from cecli.commands import Commands, SwitchCoderSignal +from cecli.commands.add import AddCommand +from cecli.commands.utils.helpers import quote_filename +from cecli.utils import is_image_file from bright_vision_core.async_bridge import ( HEARTBEAT_PULSE, @@ -33,8 +35,10 @@ from bright_vision_core.git_undo import undo_last_aider_commit_for_coder from bright_vision_core.git_workspace import create_git_workspace from bright_vision_core.headless_args import default_headless_args +from bright_vision_core.headless_persistence import apply_persistence_to_args from bright_vision_core.todo_spec_generate import build_generate_message, parse_generated_layers from bright_vision_core.slash_helpers import is_switch_coder_signal, run_slash_command_sync +from bright_vision_core.workspace_paths import attachments_dir, attachments_prefix from bright_vision_core.model_router import ( ModelRouterConfig, RouteDecision, @@ -125,6 +129,7 @@ def worker() -> None: if timeout_s is not None and time.monotonic() - started > timeout_s: if on_timeout: on_timeout() + done.wait(timeout=3.0) raise TimeoutError(f"{message} timed out after {int(timeout_s)}s") pulse += 1 emit_progress(io, label=label, message=f"{message} ({int(pulse * wait_s)}s)") @@ -190,6 +195,12 @@ def create( on_event=None, echo_to_console: bool = False, model_router: ModelRouterConfig | dict[str, Any] | None = None, + session_encrypt: bool = False, + session_key_file: str | None = None, + auto_save: bool = False, + auto_load: bool = False, + auto_save_session_name: str = "brightvision", + chat_history_file: bool | str | None = True, ) -> Session: workspace = Path(workspace_dir).resolve() if not workspace.is_dir(): @@ -203,7 +214,22 @@ def create( prev_cwd = os.getcwd() os.chdir(workspace) try: - io = EventIO(yes=yes, pretty=False, on_event=on_event, echo_to_console=echo_to_console) + cecli_meta = workspace / ".cecli" + if chat_history_file is True: + chat_hist_path = str(cecli_meta / "chat.history") + elif chat_history_file: + chat_hist_path = str(Path(chat_history_file).expanduser()) + else: + chat_hist_path = None + io_kwargs: dict[str, Any] = { + "yes": yes, + "pretty": False, + "on_event": on_event, + "echo_to_console": echo_to_console, + } + if chat_hist_path: + io_kwargs["chat_history_file"] = chat_hist_path + io = EventIO(**io_kwargs) model_name = model or models.DEFAULT_MODEL_NAME router_cfg = ( ModelRouterConfig.from_payload(model_router) @@ -237,6 +263,14 @@ def create( map_tokens = main_model.get_repo_map_tokens() commands = Commands(io, None) + headless_args = apply_persistence_to_args( + default_headless_args(yes=yes), + session_encrypt=session_encrypt, + session_key_file=session_key_file, + auto_save=auto_save, + auto_load=auto_load, + auto_save_session_name=auto_save_session_name, + ) coder = run( Coder.create( main_model=main_model, @@ -250,11 +284,20 @@ def create( map_tokens=map_tokens, commands=commands, use_git=repo is not None, - args=default_headless_args(yes=yes), + args=headless_args, ) ) commands.coder = coder rebind_coder_loop_primitives(coder) + if headless_args.auto_load: + from cecli.sessions import SessionManager + + manager = SessionManager(coder, io) + name = headless_args.auto_save_session_name or "auto-save" + try: + run(manager.load_session(name, switch=False)) + except Exception: + pass return cls(coder, io, model_router=router_cfg if router_cfg and router_cfg.enabled else None) finally: os.chdir(prev_cwd) @@ -391,8 +434,9 @@ async def _preproc_coro(): yield self.io.emit( "error", text=( - f"{err}. Stop the turn or retry with a simpler prompt. " - "Local agent mode may loop on tools (e.g. repeated ls)." + f"{err}. Use Stop, then retry without /agent for quick edits. " + "Local agent mode may loop on tools (e.g. repeated ls). " + f"Cap: VISION_SLASH_PREPROC_TIMEOUT_S (default {int(SLASH_PREPROC_TIMEOUT_S)}s)." ), ) yield self.io.emit( @@ -518,35 +562,120 @@ async def _preproc_coro(): return raise + def _resolve_workspace_file(self, raw: str) -> str | None: + """Return workspace-relative posix path for an on-disk file, or None after tool_error.""" + workspace = Path(self.coder.root).resolve() + p = Path(raw.strip().lstrip("@")) + if not p.is_absolute(): + p = workspace / p + p = p.resolve() + if not p.is_file(): + self.io.tool_error(f"Not a file: {p}") + return None + try: + return p.relative_to(workspace).as_posix() + except ValueError: + self.io.tool_error(f"File outside workspace: {p}") + return None + + def _add_matched_file_to_chat(self, rel: str) -> bool: + """Add one file like cecli ``/add`` without create-file confirms.""" + coder = self.coder + io = self.io + abs_file_path = coder.abs_root_path(rel) + + blocked = AddCommand._add_blocked_message(coder, rel) + if blocked: + io.tool_error(blocked) + return False + + if abs_file_path in coder.abs_fnames: + io.tool_output(f"{rel} is already in the chat") + return True + if abs_file_path in coder.abs_read_only_stubs_fnames: + if coder.repo and coder.repo.path_in_repo(rel): + coder.abs_read_only_stubs_fnames.remove(abs_file_path) + coder.abs_fnames.add(abs_file_path) + io.tool_output(f"Moved {rel} from read-only (stub) to editable files in the chat") + else: + io.tool_error(f"Cannot add {rel} as it's not part of the repository") + return False + elif abs_file_path in coder.abs_read_only_fnames: + if coder.repo and coder.repo.path_in_repo(rel): + coder.abs_read_only_fnames.remove(abs_file_path) + coder.abs_fnames.add(abs_file_path) + io.tool_output(f"Moved {rel} from read-only to editable files in the chat") + else: + io.tool_error(f"Cannot add {rel} as it's not part of the repository") + return False + else: + if is_image_file(rel) and not coder.main_model.info.get("supports_vision"): + io.tool_error( + f"Cannot add image file {rel} as the {coder.main_model.name} " + "does not support images." + ) + return False + content = io.read_text(abs_file_path) + if content is None: + io.tool_error(f"Unable to read {rel}") + return False + coder.abs_fnames.add(abs_file_path) + io.tool_output(f"Added {rel} to the chat") + coder.check_added_files() + if hasattr(coder, "use_enhanced_context") and coder.use_enhanced_context: + if hasattr(coder, "_calculate_context_block_tokens"): + coder._calculate_context_block_tokens() + return True + + def _finish_file_adds_like_slash_add(self) -> None: + """Match cecli ``/add`` post-success coder refresh (SwitchCoderSignal).""" + coder = self.coder + if coder.repo_map: + map_tokens = coder.repo_map.max_map_tokens + map_mul_no_files = coder.repo_map.map_mul_no_files + else: + map_tokens = 0 + map_mul_no_files = 1 + raise SwitchCoderSignal( + edit_format=coder.edit_format, + summarize_from_coder=False, + from_coder=coder, + map_tokens=map_tokens, + map_mul_no_files=map_mul_no_files, + show_announcements=False, + ) + def add_files(self, paths: list[str]) -> list[dict[str, Any]]: if not paths: return [] - workspace = Path(self.coder.root).resolve() + attach_prefix = attachments_prefix() quoted: list[str] = [] + direct_added = False for raw in paths: - raw = raw.strip().lstrip("@") - p = Path(raw) - if not p.is_absolute(): - p = workspace / p - p = p.resolve() - if not p.is_file(): - self.io.tool_error(f"Not a file: {p}") + rel = self._resolve_workspace_file(raw) + if rel is None: continue - try: - rel = p.relative_to(workspace) - quoted.append(shlex.quote(str(rel).replace("\\", "/"))) - except ValueError: - quoted.append(shlex.quote(str(p))) + if rel.startswith(attach_prefix): + if self._add_matched_file_to_chat(rel): + direct_added = True + continue + quoted.append(quote_filename(rel)) - if quoted: - run_slash_command_sync(self.coder, "add", " ".join(quoted)) + try: + if quoted: + run_slash_command_sync(self.coder, "add", " ".join(quoted)) + elif direct_added: + self._finish_file_adds_like_slash_add() + except BaseException as exc: + if not is_switch_coder_signal(exc): + raise return self.io.drain_events() def stage_uploaded_file(self, filename: str, content: bytes) -> Path: workspace = Path(self.coder.root).resolve() - attach_dir = workspace / ".aider-vision" / "attachments" + attach_dir = attachments_dir(workspace) attach_dir.mkdir(parents=True, exist_ok=True) safe_name = Path(filename).name or "upload" diff --git a/cecli b/cecli index da52a77..2ad0ae3 160000 --- a/cecli +++ b/cecli @@ -1 +1 @@ -Subproject commit da52a77f8f52c211b1463d4b66bdcd77ef5cd664 +Subproject commit 2ad0ae3b5bf54ebe3c34797f28726229018a7d87 diff --git a/docs/IPC.md b/docs/IPC.md index d4bc7b7..793c151 100644 --- a/docs/IPC.md +++ b/docs/IPC.md @@ -29,7 +29,7 @@ Add images or PDFs to the chat without sending a message: ```http POST /sessions/{session_id}/files -{"paths": [".aider-vision/attachments/screenshot.png"]} +{"paths": [".cecli/attachments/screenshot.png"]} ``` Browser upload (base64 data URLs accepted): @@ -41,11 +41,11 @@ POST /sessions/{session_id}/files/upload Response includes updated `files_in_chat` and `events` (tool_output / errors). -> **Note:** Workspace metadata paths still use `.aider-vision/` under the project root (todos, specs, attachments). Product branding is BrightVision; on-disk layout is unchanged until a dedicated migration. +> **Note:** Project-local state lives under **`.cecli/`**: Cecli uses `agents/`, `sessions/`, `logs/`, …; BrightVision adds `todos.json`, `specs/`, `attachments/`. Legacy **`.aider-vision/`**, **`.bright-vision/`**, and **`.brightvision/`** are merged into `.cecli/` on first access. ### Workspace tasks (spec-driven) -Todos live in `.aider-vision/todos.json` under the session workspace. +Todos live in `.cecli/todos.json` under the session workspace. ```http GET /sessions/{session_id}/todos @@ -84,7 +84,7 @@ GET /workspaces/todos/export?workspace=… POST /workspaces/todos/import {"workspace", "markdown", "merge": false} POST /workspaces/todos/{id}/generate-spec?workspace=…&session_id=… same body as session route POST /workspaces/todos/{id}/move?workspace=… {"direction": "up"|"down"} -POST /workspaces/todos/{id}/sync-spec-files?workspace=… import specs from `.aider-vision/specs/{id}/` +POST /workspaces/todos/{id}/sync-spec-files?workspace=… import specs from `.cecli/specs/{id}/` ``` `auto_completed` is true when a PATCH checklist update completes every item (task marked done). diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 50d62a6..0f46c75 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -77,6 +77,7 @@ Log dogfooding bugs as roadmap rows or issues with repro (workspace path, file p | 3 | **Done** | Stop in-flight turn (`cancelSend` + AbortSignal on fetch) | | 4 | **Done** | Queue messages while busy (`useVisionSession` queue + Queue button in `ChatPanel`) | | 12 | **Done** | `/add` / `/drop` path completion via Tauri `complete_workspace_path` + Tab in chat | +| **33** | **Partial** | **Cecli session persistence** — `--auto-save` / `--auto-load`, `.cecli/chat.history`, optional AES-256-GCM (`cecli/session_crypto.py`, `CECLI_SESSION_KEY`); BrightVision Settings + keychain (`session_key.rs`). **Tests:** BrightVision `tests/core/test_session_*`, `test_headless_persistence.py`, `test_http_session_persistence.py`, `test_sessions.py`; cecli `tests/basic/test_session_crypto.py`, `test_session_args.py`, `test_sessions_manager.py`; e2e `settings-config.spec.ts`. **Open:** upstream cecli PR; hydrate React chat after `/load-session`; encrypt `chat.history`. | | **32** | **Partial** | **Suggested files tray** — parse assistant **Answer** for repo-relative paths (`-` / `*` / `1.` lists + backticks); tray above chat input with **Add all**, **Queue `/add`**, dismiss; uses `addFiles` + message queue (#4). Clearer copy when adds fail (ignore vs wrong workspace): `addFileMessages.ts`, cecli `add.py`. **Open:** e2e polish, tree picker tie-in (#28). See [§ #32 design](#32-suggested-files--queued-add) | ## Approvals, workspace & engine @@ -394,7 +395,7 @@ Prefer **permissive licenses** and **small bundle** ([AGENTS.md](../AGENTS.md)). 2. **Chat** — **Agents** chip row (`ChatAgentBar`): `/agent`, `/invoke-agent`, `/spawn-agent`, `/reap-agent`; registered sub-agent chips (click → invoke, double-click → spawn). 3. **Settings → Agents & sub-agents** — docs links + loaded registry when session is live. 4. **Commands** — agent slash commands merged into palette with fallback summaries. -5. **Headless guardrails** — `VISION_SLASH_PREPROC_TIMEOUT_S` (default 600s) for `/agent` preproc; `interrupt_turn` on SSE disconnect; default `agent_config` JSON (`command_timeout` 45s). +5. **Headless guardrails** — `VISION_SLASH_PREPROC_TIMEOUT_S` (default 300s) for `/agent` preproc; `POST /sessions/{id}/interrupt` + SSE disconnect → `interrupt_turn`; default `agent_config` JSON (`command_timeout` 45s). **Open / v2:** diff --git a/docs/TESTING.md b/docs/TESTING.md index 1759c05..4b4c4a7 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -43,6 +43,24 @@ yarn test:watch Covers chat stream parsing (including optimistic user-message reconcile), commit graph layout, auto-stage policy, session lifecycle, git labels. +**Cecli session persistence / encryption** (run with activated venv): + +```bash +source activate.sh +# Cecli submodule (upstream PR surface) +python -m pytest cecli/tests/basic/test_session_crypto.py \ + cecli/tests/basic/test_session_args.py \ + cecli/tests/basic/test_sessions_manager.py -q +# BrightVision integration +python -m pytest \ + tests/core/test_session_crypto.py \ + tests/core/test_headless_persistence.py \ + tests/core/test_sessions.py \ + tests/core/test_http_session_persistence.py -q +``` + +Or `yarn test:bright-core` (BrightVision `tests/core/*` modules; run cecli tests before upstream PR). + ## Rust (Tauri git_ops) ```bash @@ -72,7 +90,7 @@ yarn test:e2e | `confirm-flow.spec.ts` | Confirm banner | | `chat-context.spec.ts` | Folder attach | | `tasks-workspace.spec.ts` | Tasks + generate-spec | -| `settings-config.spec.ts` | Settings persistence | +| `settings-config.spec.ts` | Settings persistence; Cecli session encrypt/auto-save API flags | | `tauri-git.spec.ts` | Git panel (mock Tauri) | | `path-completion.spec.ts` | `/add` Tab (desktop vs web) | | `file-upload.spec.ts` | Upload + native attach mock | diff --git a/e2e/settings-config.spec.ts b/e2e/settings-config.spec.ts index 582c914..2840788 100644 --- a/e2e/settings-config.spec.ts +++ b/e2e/settings-config.spec.ts @@ -23,6 +23,50 @@ test.describe('Settings (roadmap #17, #28 persistence)', () => { expect(stored).toContain('"autoStageOnDone":false') }) + test('session persistence toggles persist in localStorage', async ({ page }) => { + await page.getByTestId('settings-session-encrypt').click() + await page.getByTestId('settings-auto-save-session').click() + await page.getByTestId('settings-auto-save-session-name').fill('e2e-session') + await page.getByRole('button', { name: 'Save' }).click() + const stored = await page.evaluate((key) => localStorage.getItem(key), E2E_CONFIG_STORAGE_KEY) + expect(stored).toContain('"sessionEncrypt":true') + expect(stored).toContain('"autoSaveSession":true') + expect(stored).toContain('"autoSaveSessionName":"e2e-session"') + }) + + test('session create sends persistence flags to core API', async ({ page }) => { + let body: Record = {} + await page.addInitScript((cfg) => { + localStorage.setItem('vision-welcome-dismissed', '1') + localStorage.setItem( + 'bright-vision-config', + JSON.stringify({ + ...cfg, + sessionEncrypt: true, + autoSaveSession: true, + autoLoadSession: false, + autoSaveSessionName: 'e2e-api', + chatHistoryFile: true, + }) + ) + }, E2E_CONFIG) + await installMockCoreApi(page, { + onSessionCreate: (b) => { + body = b + }, + }) + await page.goto('/') + await page.getByTestId('nav-terminal').click() + await page.getByTestId('terminal-start').click() + await expect(page.getByTestId('session-status')).toContainText('Session active', { + timeout: 15_000, + }) + expect(body.session_encrypt).toBe(true) + expect(body.auto_save).toBe(true) + expect(body.auto_save_session_name).toBe('e2e-api') + expect(body.chat_history_file).toBe(true) + }) + test('session create sends auto_commits false when prompt before commit', async ({ page }) => { let autoCommits: boolean | undefined await page.addInitScript((cfg) => { diff --git a/package.json b/package.json index d620054..1f6a05f 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "test:all": "yarn test:full", "test:local:sh": "sh scripts/test-local.sh", "test:git-workspace": "python -m pytest tests/core/test_git_workspace.py tests/core/test_http_api.py -q", - "test:bright-core": "python -m pytest tests/core/test_git_workspace.py tests/core/test_workspace_todos.py tests/core/test_http_api.py tests/core/test_http_session_todos.py tests/core/test_superproject_integration.py tests/core/test_headless_args.py tests/core/test_headless_agent.py tests/core/test_llm_ollama.py -q", + "test:bright-core": "python -m pytest tests/core/test_git_workspace.py tests/core/test_workspace_todos.py tests/core/test_http_api.py tests/core/test_http_interrupt.py tests/core/test_http_session_todos.py tests/core/test_http_session_persistence.py tests/core/test_superproject_integration.py tests/core/test_headless_args.py tests/core/test_headless_persistence.py tests/core/test_headless_agent.py tests/core/test_session_crypto.py tests/core/test_sessions.py tests/core/test_llm_ollama.py -q", "bench:leaderboard": "node scripts/build-bench-leaderboard.mjs", "verify:submodule": "sh scripts/verify_submodule.sh", "sync:core": "bash cecli/scripts/sync_bright_vision.sh" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3567545..7ba0e28 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -146,6 +146,9 @@ dependencies = [ name = "bright-vision" version = "0.1.2-bright4" dependencies = [ + "base64 0.22.1", + "getrandom 0.2.17", + "keyring", "reqwest 0.12.28", "serde", "serde_json", @@ -1718,6 +1721,16 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "log", + "zeroize", +] + [[package]] name = "leb128fmt" version = "0.1.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index aa573ef..a2a0e6a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -17,6 +17,9 @@ serde_json = "1" tokio = { version = "1", features = ["full"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } sysinfo = "0.33" +keyring = "3" +getrandom = "0.2" +base64 = "0.22" [features] custom-protocol = ["tauri/custom-protocol"] diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 249c651..4ac9923 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -5,6 +5,7 @@ mod workspace_editor; mod local_llm_config; mod local_llm_runtime; mod resource_monitor; +mod session_key; use std::path::{Path, PathBuf}; use std::process::Stdio; @@ -252,6 +253,7 @@ async fn start_core_api( extra_params: String, ollama_api_base: String, port: u16, + session_encrypt: Option, ) -> Result { let mut guard = state.serve_child.lock().await; if let Some(ref mut child) = *guard { @@ -297,6 +299,10 @@ async fn start_core_api( if !ollama_api_base.trim().is_empty() { cmd.env("OLLAMA_API_BASE", ollama_api_base.trim()); } + if session_encrypt.unwrap_or(false) { + let key_b64 = session_key::ensure_session_encryption_key()?; + cmd.env("CECLI_SESSION_KEY", key_b64); + } cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); let mut child = cmd.spawn().map_err(|e| format!("Failed to start Vision API: {e}"))?; @@ -726,6 +732,9 @@ fn estimate_paths_context_chars(working_dir: String, paths: Vec) -> Resu const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "bmp", "webp", "tiff", "pdf"]; +/// Cecli project tree; BrightVision uses ``todos.json``, ``specs/``, ``attachments/`` subtrees. +const WORKSPACE_META_DIR: &str = ".cecli"; + fn is_image_ext(path: &Path) -> bool { path.extension() .and_then(|e| e.to_str()) @@ -735,7 +744,7 @@ fn is_image_ext(path: &Path) -> bool { fn workspace_todos_path(working_dir: &str) -> PathBuf { normalize_project_workspace(working_dir) - .join(".aider-vision") + .join(WORKSPACE_META_DIR) .join("todos.json") } @@ -896,12 +905,12 @@ fn write_workspace_todos(working_dir: String, store: TodoStoreJson) -> Result<() fn todo_specs_dir(working_dir: &str, todo_id: &str) -> PathBuf { normalize_project_workspace(working_dir) - .join(".aider-vision") + .join(WORKSPACE_META_DIR) .join("specs") .join(todo_id) } -/// Load requirements/design/tasks markdown from ``.aider-vision/specs/{id}/`` into todos.json. +/// Load requirements/design/tasks markdown from ``.brightvision/specs/{id}/`` into todos.json. #[tauri::command] fn import_todo_spec_files(working_dir: String, todo_id: String) -> Result { let folder = todo_specs_dir(&working_dir, &todo_id); @@ -943,7 +952,7 @@ fn import_todo_spec_files(working_dir: String, todo_id: String) -> Result = Vec::new(); @@ -1135,6 +1144,8 @@ fn main() { }) .invoke_handler(tauri::generate_handler![ start_core_api, + session_key::ensure_session_encryption_key, + session_key::clear_session_encryption_key, stop_core_api, drain_core_api_logs, default_workspace, diff --git a/src-tauri/src/session_key.rs b/src-tauri/src/session_key.rs new file mode 100644 index 0000000..5fd7dc2 --- /dev/null +++ b/src-tauri/src/session_key.rs @@ -0,0 +1,53 @@ +//! OS keychain storage for the Cecli session encryption key (32 bytes, urlsafe base64). + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use getrandom::getrandom; + +const SERVICE: &str = "com.digitaldefiance.bright-vision"; +const ACCOUNT: &str = "session-encryption-key"; +const KEY_LEN: usize = 32; + +fn entry() -> Result { + keyring::Entry::new(SERVICE, ACCOUNT).map_err(|e| e.to_string()) +} + +fn valid_key_b64(text: &str) -> bool { + let trimmed = text.trim(); + if trimmed.is_empty() { + return false; + } + let padded = format!("{trimmed}{}", "=".repeat((4 - trimmed.len() % 4) % 4)); + let Ok(bytes) = URL_SAFE_NO_PAD.decode(padded.as_bytes()) else { + return false; + }; + bytes.len() == KEY_LEN +} + +/// Return urlsafe-base64 session key; create and store in the OS keychain if missing. +#[tauri::command] +pub fn ensure_session_encryption_key() -> Result { + let entry = entry()?; + if let Ok(existing) = entry.get_password() { + if valid_key_b64(&existing) { + return Ok(existing.trim().to_string()); + } + } + let mut key = [0u8; KEY_LEN]; + getrandom(&mut key).map_err(|e| format!("random key generation failed: {e}"))?; + let encoded = URL_SAFE_NO_PAD.encode(key); + entry + .set_password(&encoded) + .map_err(|e| format!("keychain write failed: {e}"))?; + Ok(encoded) +} + +#[tauri::command] +pub fn clear_session_encryption_key() -> Result<(), String> { + let entry = entry()?; + match entry.delete_credential() { + Ok(()) => Ok(()), + Err(keyring::Error::NoEntry) => Ok(()), + Err(e) => Err(e.to_string()), + } +} diff --git a/src/App.tsx b/src/App.tsx index 2871fc0..b191745 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -210,6 +210,21 @@ function migrateConfig(raw: Partial & Record): Vi if (typeof merged.manageLocalLlm !== 'boolean') { merged.manageLocalLlm = true } + if (typeof merged.sessionEncrypt !== 'boolean') { + merged.sessionEncrypt = false + } + if (typeof merged.autoSaveSession !== 'boolean') { + merged.autoSaveSession = false + } + if (typeof merged.autoLoadSession !== 'boolean') { + merged.autoLoadSession = false + } + if (typeof merged.chatHistoryFile !== 'boolean') { + merged.chatHistoryFile = true + } + if (typeof merged.autoSaveSessionName !== 'string' || !merged.autoSaveSessionName.trim()) { + merged.autoSaveSessionName = 'brightvision' + } if ( merged.coreEnginePath === 'aider-vision-core' || merged.coreEnginePath === 'bright-vision-core' || diff --git a/src/components/settings/SessionPersistenceSection.tsx b/src/components/settings/SessionPersistenceSection.tsx new file mode 100644 index 0000000..fc68295 --- /dev/null +++ b/src/components/settings/SessionPersistenceSection.tsx @@ -0,0 +1,102 @@ +import { + Alert, + FormControlLabel, + Paper, + Stack, + Switch, + TextField, + Typography, +} from '@mui/material' +import type { VisionConfig } from '../../ipc/config' +import { isTauriRuntime } from '../../ipc/isTauri' +import { WORKSPACE_META_DIR } from '../../brand' + +interface SessionPersistenceSectionProps { + config: VisionConfig + onChange: (next: VisionConfig) => void +} + +export function SessionPersistenceSection({ config, onChange }: SessionPersistenceSectionProps) { + const desktop = isTauriRuntime() + const patch = (partial: Partial) => onChange({ ...config, ...partial }) + + return ( + + + Session history (Cecli) + + + Uses Cecli builtins under {WORKSPACE_META_DIR}/sessions/ and optional{' '} + {WORKSPACE_META_DIR}/chat.history. May contain secrets and code from your + project — add to .gitignore if needed. + + + {!desktop && config.sessionEncrypt && ( + + Encrypted sessions require the desktop app (OS keychain). In the browser, turn off + encryption or set CECLI_SESSION_KEY yourself for the Vision API process. + + )} + + + patch({ chatHistoryFile: v })} + data-testid="settings-chat-history-file" + /> + } + label="Append chat to .cecli/chat.history" + /> + patch({ autoSaveSession: v })} + data-testid="settings-auto-save-session" + /> + } + label="Auto-save session (Cecli --auto-save)" + /> + patch({ autoLoadSession: v })} + disabled={!config.autoSaveSession} + data-testid="settings-auto-load-session" + /> + } + label="Restore auto-save on session start (--auto-load)" + /> + patch({ autoSaveSessionName: e.target.value.trim() || 'brightvision' })} + disabled={!config.autoSaveSession} + helperText="File: .cecli/sessions/.json" + sx={{ maxWidth: 360, mt: 0.5 }} + inputProps={{ 'data-testid': 'settings-auto-save-session-name' }} + /> + patch({ sessionEncrypt: v })} + data-testid="settings-session-encrypt" + /> + } + label="Encrypt saved sessions (AES-256-GCM)" + /> + {config.sessionEncrypt && desktop && ( + + Key is stored in the OS keychain and passed to the Vision API as CECLI_SESSION_KEY. You + can also use /save-session and /load-session in chat. + + )} + + + ) +} diff --git a/src/components/settings/SettingsPanel.tsx b/src/components/settings/SettingsPanel.tsx index 1b99d0d..5afdb7b 100644 --- a/src/components/settings/SettingsPanel.tsx +++ b/src/components/settings/SettingsPanel.tsx @@ -39,6 +39,7 @@ import { ModelRouterSection } from './ModelRouterSection' import type { ModelRouterPrefs } from '../../theme/modelRouterPrefs' import type { ThinkingStatsStore } from '../../utils/thinkingStats' import { AppVersionSection } from './AppVersionSection' +import { SessionPersistenceSection } from './SessionPersistenceSection' import { AgentsSection } from './AgentsSection' import type { AppVersions } from '../../hooks/useAppVersions' import type { SubAgentInfo } from '../../ipc/agentCommands' @@ -414,6 +415,8 @@ export function SettingsPanel({ onChange={onResourceOverlayPrefsChange} /> + + diff --git a/src/ipc/config.ts b/src/ipc/config.ts index 069b743..9b20a0f 100644 --- a/src/ipc/config.ts +++ b/src/ipc/config.ts @@ -42,6 +42,16 @@ export interface VisionConfig { coreApiToken: string /** Optional paths (relative to workspace) added to the core session. */ contextFiles: string[] + /** Encrypt `.cecli/sessions/` via cecli (desktop: key in OS keychain). */ + sessionEncrypt: boolean + /** Cecli `--auto-save` for `.cecli/sessions/.json`. */ + autoSaveSession: boolean + /** Cecli `--auto-load` on session start. */ + autoLoadSession: boolean + /** Basename under `.cecli/sessions/` (default brightvision). */ + autoSaveSessionName: string + /** Append chat transcript to `.cecli/chat.history`. */ + chatHistoryFile: boolean } export const DEFAULT_CONFIG: VisionConfig = { @@ -59,6 +69,11 @@ export const DEFAULT_CONFIG: VisionConfig = { coreApiUrl: 'http://127.0.0.1:8741', coreApiToken: '', contextFiles: [], + sessionEncrypt: false, + autoSaveSession: false, + autoLoadSession: false, + autoSaveSessionName: 'brightvision', + chatHistoryFile: true, } export function parseContextFilesInput(raw: string): string[] { diff --git a/src/ipc/httpClient.ts b/src/ipc/httpClient.ts index eda05c7..4b5dfd4 100644 --- a/src/ipc/httpClient.ts +++ b/src/ipc/httpClient.ts @@ -133,6 +133,11 @@ export class CoreHttpClient { auto_commits?: boolean dirty_commits?: boolean dry_run?: boolean + session_encrypt?: boolean + auto_save?: boolean + auto_load?: boolean + auto_save_session_name?: string + chat_history_file?: boolean }): Promise { const res = await fetch(`${this.baseUrl}/sessions`, { method: 'POST', @@ -527,6 +532,17 @@ export class CoreHttpClient { return normalizeStore(await res.json()) } + async interruptTurn(sessionId: string): Promise { + const res = await fetch(`${this.baseUrl}/sessions/${sessionId}/interrupt`, { + method: 'POST', + headers: this.headers(false), + }) + if (!res.ok) { + const detail = await res.text() + throw new Error(`interrupt turn: ${res.status} ${detail}`) + } + } + async *sendMessage( sessionId: string, content: string, diff --git a/src/ipc/visionApi.ts b/src/ipc/visionApi.ts index 4101d00..ad0c824 100644 --- a/src/ipc/visionApi.ts +++ b/src/ipc/visionApi.ts @@ -93,6 +93,9 @@ export function createVisionApiSession( detail: cfg.coreEnginePath, progress: 0.2, }) + if (cfg.sessionEncrypt) { + await invokeWithTimeout('ensure_session_encryption_key', {}) + } url = await invokeWithTimeout('start_core_api', { workingDir: cfg.workingDir, coreEnginePath: cfg.coreEnginePath, @@ -100,6 +103,7 @@ export function createVisionApiSession( extraParams: cfg.extraParams, ollamaApiBase: cfg.ollamaApiBase, port: 8741, + sessionEncrypt: cfg.sessionEncrypt, }) desktopStartedServe = true } @@ -122,6 +126,11 @@ export function createVisionApiSession( files: cfg.contextFiles?.length ? cfg.contextFiles : undefined, auto_yes: false, auto_commits: !cfg.promptBeforeCommit, + session_encrypt: cfg.sessionEncrypt, + auto_save: cfg.autoSaveSession, + auto_load: cfg.autoLoadSession, + auto_save_session_name: cfg.autoSaveSessionName, + chat_history_file: cfg.chatHistoryFile, }) sessionId = session.session_id sessionInfo = session @@ -217,6 +226,11 @@ export function createVisionApiSession( cancelSend() { sendAbort?.abort() sendAbort = null + const sid = sessionId + const c = client + if (sid && c) { + void c.interruptTurn(sid).catch(() => {}) + } }, async submitConfirm(confirmId, answer) { diff --git a/tests/core/test_headless_args.py b/tests/core/test_headless_args.py index f5499f2..2fce3dd 100644 --- a/tests/core/test_headless_args.py +++ b/tests/core/test_headless_args.py @@ -25,6 +25,11 @@ "disable_scraping", "use_enhanced_map", "agent_config", + "auto_save", + "auto_load", + "auto_save_session_name", + "session_encrypt", + "session_key_file", ) diff --git a/tests/core/test_headless_persistence.py b/tests/core/test_headless_persistence.py new file mode 100644 index 0000000..35c050d --- /dev/null +++ b/tests/core/test_headless_persistence.py @@ -0,0 +1,77 @@ +"""Headless persistence flags (Cecli session encrypt / auto-save).""" + +from __future__ import annotations + +import base64 +import os +from types import SimpleNamespace + +import pytest + +from bright_vision_core.headless_args import default_headless_args +from bright_vision_core.headless_persistence import ( + apply_persistence_to_args, + session_crypto_key_available, +) + + +@pytest.fixture +def key32(): + return os.urandom(32) + + +@pytest.fixture +def key_b64(key32): + return base64.urlsafe_b64encode(key32).decode().rstrip("=") + + +def test_default_headless_args_includes_persistence_fields(): + args = default_headless_args() + assert args.session_encrypt is False + assert args.auto_save is False + assert args.auto_load is False + assert args.auto_save_session_name == "auto-save" + assert args.session_key_file is None + + +def test_apply_persistence_sets_flags(): + args = apply_persistence_to_args( + default_headless_args(), + session_encrypt=False, + auto_save=True, + auto_load=True, + auto_save_session_name="brightvision", + ) + assert args.auto_save is True + assert args.auto_load is True + assert args.auto_save_session_name == "brightvision" + + +def test_apply_encrypt_disabled_without_key(monkeypatch): + monkeypatch.delenv("CECLI_SESSION_KEY", raising=False) + args = apply_persistence_to_args(default_headless_args(), session_encrypt=True) + assert args.session_encrypt is False + + +def test_apply_encrypt_enabled_with_env(monkeypatch, key_b64): + monkeypatch.setenv("CECLI_SESSION_KEY", key_b64) + args = apply_persistence_to_args(default_headless_args(), session_encrypt=True) + assert args.session_encrypt is True + assert session_crypto_key_available() + + +def test_apply_encrypt_enabled_with_key_file(tmp_path, key32): + path = tmp_path / "key" + path.write_text(base64.urlsafe_b64encode(key32).decode(), encoding="utf-8") + args = apply_persistence_to_args( + default_headless_args(), + session_encrypt=True, + session_key_file=str(path), + ) + assert args.session_encrypt is True + + +def test_apply_persistence_does_not_mutate_default_template(): + template = default_headless_args() + apply_persistence_to_args(template, auto_save=True) + assert template.auto_save is False diff --git a/tests/core/test_http_interrupt.py b/tests/core/test_http_interrupt.py new file mode 100644 index 0000000..a56d1f2 --- /dev/null +++ b/tests/core/test_http_interrupt.py @@ -0,0 +1,34 @@ +"""POST /sessions/{id}/interrupt — UI Stop.""" + +from __future__ import annotations + +import unittest + +from fastapi.testclient import TestClient + +from bright_vision_core.http_api import app +from cecli.utils import GitTemporaryDirectory, make_repo + + +class TestHttpInterrupt(unittest.TestCase): + def test_interrupt_unknown_session_404(self): + client = TestClient(app) + res = client.post("/sessions/no-such-session/interrupt") + self.assertEqual(res.status_code, 404) + + def test_interrupt_ok(self): + with GitTemporaryDirectory() as temp_dir: + make_repo(temp_dir) + client = TestClient(app) + sess = client.post( + "/sessions", + json={"workspace": temp_dir, "model": "gpt-4o", "auto_yes": True}, + ) + session_id = sess.json()["session_id"] + res = client.post(f"/sessions/{session_id}/interrupt") + self.assertEqual(res.status_code, 200) + self.assertEqual(res.json(), {"ok": True}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/core/test_http_session_persistence.py b/tests/core/test_http_session_persistence.py new file mode 100644 index 0000000..02e5584 --- /dev/null +++ b/tests/core/test_http_session_persistence.py @@ -0,0 +1,121 @@ +"""HTTP API: Cecli session persistence and encryption flags on create.""" + +from __future__ import annotations + +import base64 +import os +import unittest +from pathlib import Path + +try: + from fastapi.testclient import TestClient + + from bright_vision_core.http_api import _sessions, app + from bright_vision_core.http_auth import configure_auth, reset_auth_for_tests +except ImportError: + TestClient = None + app = None + configure_auth = None + reset_auth_for_tests = None + +from cecli import session_crypto +from cecli.sessions import SessionManager +from cecli.utils import GitTemporaryDirectory + + +@unittest.skipIf(TestClient is None, "fastapi not installed") +class TestHttpSessionPersistence(unittest.TestCase): + def setUp(self): + _sessions.clear() + reset_auth_for_tests() + configure_auth("127.0.0.1") + self._prev_key = os.environ.pop("CECLI_SESSION_KEY", None) + + def tearDown(self): + reset_auth_for_tests() + if self._prev_key is not None: + os.environ["CECLI_SESSION_KEY"] = self._prev_key + else: + os.environ.pop("CECLI_SESSION_KEY", None) + + def _set_session_key(self) -> bytes: + key = os.urandom(32) + os.environ["CECLI_SESSION_KEY"] = base64.urlsafe_b64encode(key).decode().rstrip("=") + return key + + def test_create_session_passes_persistence_to_coder(self): + self._set_session_key() + with GitTemporaryDirectory() as root: + client = TestClient(app) + res = client.post( + "/sessions", + json={ + "workspace": root, + "model": "gpt-4o", + "session_encrypt": True, + "auto_save": True, + "auto_load": False, + "auto_save_session_name": "bv-http", + "chat_history_file": True, + }, + ) + if res.status_code == 400: + self.skipTest(f"Could not create session (model/env): {res.text}") + self.assertEqual(res.status_code, 200, res.text) + session_id = res.json()["session_id"] + session = _sessions[session_id] + self.assertTrue(session.coder.args.session_encrypt) + self.assertTrue(session.coder.args.auto_save) + self.assertEqual(session.coder.args.auto_save_session_name, "bv-http") + self.assertIsNotNone(getattr(session.io, "chat_history_file", None)) + chat_hist = Path(root) / ".cecli" / "chat.history" + self.assertTrue( + str(chat_hist) == str(session.io.chat_history_file) + or chat_hist.name in str(session.io.chat_history_file) + ) + + def test_encrypted_save_via_session_manager(self): + self._set_session_key() + with GitTemporaryDirectory() as root: + client = TestClient(app) + res = client.post( + "/sessions", + json={ + "workspace": root, + "model": "gpt-4o", + "session_encrypt": True, + }, + ) + if res.status_code == 400: + self.skipTest(f"Could not create session (model/env): {res.text}") + session_id = res.json()["session_id"] + session = _sessions[session_id] + manager = SessionManager(session.coder, session.io) + self.assertTrue(manager.save_session("encrypted-save", output=False)) + path = Path(root) / ".cecli" / "sessions" / "encrypted-save.json" + self.assertTrue(path.is_file()) + raw = path.read_bytes() + self.assertTrue(session_crypto.is_encrypted_payload(raw)) + data = session_crypto.decrypt_session_bytes(raw, session_crypto.resolve_key()) + self.assertEqual(data.get("session_name"), "encrypted-save") + self.assertIn("version", data) + + def test_plaintext_save_when_encryption_off(self): + with GitTemporaryDirectory() as root: + client = TestClient(app) + res = client.post( + "/sessions", + json={"workspace": root, "model": "gpt-4o", "session_encrypt": False}, + ) + if res.status_code == 400: + self.skipTest(f"Could not create session (model/env): {res.text}") + session = _sessions[res.json()["session_id"]] + manager = SessionManager(session.coder, session.io) + self.assertTrue(manager.save_session("plain", output=False)) + raw = (Path(root) / ".cecli" / "sessions" / "plain.json").read_bytes() + self.assertFalse(session_crypto.is_encrypted_payload(raw)) + self.assertTrue(raw.startswith(b"{")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/core/test_session_crypto.py b/tests/core/test_session_crypto.py new file mode 100644 index 0000000..edcbafc --- /dev/null +++ b/tests/core/test_session_crypto.py @@ -0,0 +1,83 @@ +"""Cecli session encryption helpers (used by BrightVision headless sessions).""" + +import base64 +import json +import os +from pathlib import Path + +import pytest + +from cecli import session_crypto + + +@pytest.fixture +def key32(): + return os.urandom(32) + + +@pytest.fixture +def key_b64(key32): + return base64.urlsafe_b64encode(key32).decode().rstrip("=") + + +def test_encrypt_roundtrip(key32): + payload = {"version": 1, "session_name": "bv", "model": "ollama_chat/test"} + blob = session_crypto.encrypt_session_dict(payload, key32) + assert session_crypto.is_encrypted_payload(blob) + assert session_crypto.decrypt_session_bytes(blob, key32) == payload + + +def test_is_encrypted_rejects_plain_json(): + raw = json.dumps({"version": 1}).encode("utf-8") + assert not session_crypto.is_encrypted_payload(raw) + + +def test_decrypt_plain_json_without_encrypt_flag(key32): + payload = {"version": 1, "session_name": "legacy"} + raw = json.dumps(payload).encode("utf-8") + assert session_crypto.decrypt_session_bytes(raw, key32) == payload + + +def test_wrong_key_raises(key32): + blob = session_crypto.encrypt_session_dict({"version": 1}, key32) + with pytest.raises(session_crypto.SessionCryptoError): + session_crypto.decrypt_session_bytes(blob, os.urandom(32)) + + +def test_invalid_key_length_rejected(): + with pytest.raises(session_crypto.SessionCryptoError): + session_crypto.encrypt_session_dict({"version": 1}, b"short") + + +def test_resolve_key_from_env(monkeypatch, key_b64, key32): + monkeypatch.setenv(session_crypto.KEY_ENV, key_b64) + assert session_crypto.resolve_key() == key32 + + +def test_resolve_key_from_file(tmp_path, key32): + path = tmp_path / "session.key" + path.write_text(base64.urlsafe_b64encode(key32).decode(), encoding="utf-8") + assert session_crypto.resolve_key(key_file=path) == key32 + + +def test_resolve_key_missing_returns_none(monkeypatch): + monkeypatch.delenv(session_crypto.KEY_ENV, raising=False) + assert session_crypto.resolve_key() is None + + +def test_encrypted_file_roundtrip_on_disk(tmp_path, key32): + path = tmp_path / "sess.json" + payload = {"version": 1, "session_name": "disk"} + path.write_bytes(session_crypto.encrypt_session_dict(payload, key32)) + raw = path.read_bytes() + assert session_crypto.is_encrypted_payload(raw) + assert session_crypto.decrypt_session_bytes(raw, key32) == payload + + +def test_headless_persistence_requires_key(monkeypatch): + from bright_vision_core.headless_args import default_headless_args + from bright_vision_core.headless_persistence import apply_persistence_to_args + + monkeypatch.delenv(session_crypto.KEY_ENV, raising=False) + args = apply_persistence_to_args(default_headless_args(), session_encrypt=True) + assert args.session_encrypt is False diff --git a/tests/core/test_sessions.py b/tests/core/test_sessions.py index c6611e1..6ff86e1 100644 --- a/tests/core/test_sessions.py +++ b/tests/core/test_sessions.py @@ -1,16 +1,57 @@ +import base64 import json import os +from pathlib import Path +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock import pytest +from cecli import session_crypto from cecli.io import InputOutput from cecli.sessions import SessionManager +@pytest.fixture(autouse=True) +def _clear_mock_coder_args(mock_coder): + """Reset args after tests that replace them (e.g. encrypt_coder).""" + yield + mock_coder.args = SimpleNamespace( + model="test_model", + weak_model="test_weak_model", + editor_model="test_editor_model", + agent_model="test_agent_model", + editor_edit_format="editor-diff", + verbose=False, + session_encrypt=False, + session_key_file=None, + ) + + +def _prepare_workspace(mock_coder, tmp_path) -> None: + """Point coder at tmp_path and create session dir + files referenced in saves.""" + root = Path(tmp_path) + mock_coder.abs_root_path.side_effect = lambda x: str(root / x) + (root / ".cecli" / "sessions").mkdir(parents=True, exist_ok=True) + (root / "file1.py").write_text("", encoding="utf-8") + (root / "file2.py").write_text("", encoding="utf-8") + + @pytest.fixture -def mock_coder(): +def mock_coder(monkeypatch): """Fixture to create a mock coder with necessary attributes.""" + conv_manager = MagicMock() + conv_manager.get_messages_dict.return_value = [] + files_manager = MagicMock() + monkeypatch.setattr( + "cecli.sessions.ConversationService.get_manager", + lambda _coder: conv_manager, + ) + monkeypatch.setattr( + "cecli.sessions.ConversationService.get_files", + lambda _coder: files_manager, + ) + coder = MagicMock() coder.abs_fnames = {"/path/to/file1.py"} coder.abs_read_only_fnames = {"/path/to/file2.py"} @@ -32,13 +73,13 @@ def mock_coder(): main_model.agent_model.name = "test_agent_model" main_model.editor_edit_format = "editor-diff" coder.main_model = main_model - - # Mock ConversationService methods - mock_conversation_service = MagicMock() - mock_conversation_service.get_manager.return_value.get_messages_dict.return_value = [] - coder.conversation_service = mock_conversation_service + monkeypatch.setattr( + "cecli.sessions.models.Model", + lambda *args, **kwargs: main_model, + ) # Mock other necessary methods and attributes + coder.format_chat_chunks = MagicMock() coder.get_rel_fname.side_effect = lambda x: os.path.basename(x) coder.abs_root_path.side_effect = lambda x: f"/test/root/{x}" coder.local_agent_folder.side_effect = lambda x: f".cecli/{x}" @@ -47,6 +88,17 @@ def mock_coder(): coder.mcp_manager = None coder.skills_manager = None coder.io.read_text.return_value = "some todo content" + # None avoids MagicMock inventing session_encrypt=True; load needs real fields. + coder.args = SimpleNamespace( + model="test_model", + weak_model="test_weak_model", + editor_model="test_editor_model", + agent_model="test_agent_model", + editor_edit_format="editor-diff", + verbose=False, + session_encrypt=False, + session_key_file=None, + ) return coder @@ -57,11 +109,41 @@ def session_manager(mock_coder): return SessionManager(mock_coder, mock_coder.io) +@pytest.fixture +def session_key32(): + return os.urandom(32) + + +@pytest.fixture +def session_key_env(monkeypatch, session_key32): + b64 = base64.urlsafe_b64encode(session_key32).decode().rstrip("=") + monkeypatch.setenv(session_crypto.KEY_ENV, b64) + return session_key32 + + +@pytest.fixture +def encrypt_coder(mock_coder, session_key_env, monkeypatch): + mock_coder.args = SimpleNamespace( + model="test_model", + weak_model="test_weak_model", + editor_model="test_editor_model", + agent_model="test_agent_model", + editor_edit_format="editor-diff", + verbose=False, + session_encrypt=True, + session_key_file=None, + ) + monkeypatch.setattr( + "cecli.sessions.models.Model", + lambda *args, **kwargs: mock_coder.main_model, + ) + return mock_coder + + def test_save_session(session_manager, mock_coder, tmp_path): """Test saving a session.""" - session_dir = tmp_path / ".cecli" / "sessions" - os.makedirs(session_dir, exist_ok=True) - mock_coder.abs_root_path.side_effect = lambda x: str(tmp_path / x) + _prepare_workspace(mock_coder, tmp_path) + session_dir = Path(tmp_path) / ".cecli" / "sessions" session_name = "test_session" success = session_manager.save_session(session_name, output=False) @@ -82,9 +164,8 @@ def test_save_session(session_manager, mock_coder, tmp_path): @pytest.mark.asyncio async def test_load_session_restores_edit_format(session_manager, mock_coder, tmp_path): """Test that loading a session restores the edit_format.""" - session_dir = tmp_path / ".cecli" / "sessions" - os.makedirs(session_dir, exist_ok=True) - mock_coder.abs_root_path.side_effect = lambda x: str(tmp_path / x) + _prepare_workspace(mock_coder, tmp_path) + session_dir = Path(tmp_path) / ".cecli" / "sessions" # 1. Save a session with a specific edit_format mock_coder.edit_format = "agent" @@ -97,7 +178,6 @@ async def test_load_session_restores_edit_format(session_manager, mock_coder, tm # 3. Load the session session_file = session_dir / f"{session_name}.json" - # Mock the SwitchCoderSignal to capture the edit_format it's called with from cecli import commands original_switch_coder_signal = commands.SwitchCoderSignal @@ -117,16 +197,14 @@ def __init__(self, edit_format, **kwargs): assert excinfo.value.edit_format == "agent" finally: - # Restore the original SwitchCoderSignal commands.SwitchCoderSignal = original_switch_coder_signal @pytest.mark.asyncio async def test_load_session_restores_architect_mode(session_manager, mock_coder, tmp_path): """Test that loading a session restores architect mode.""" - session_dir = tmp_path / ".cecli" / "sessions" - os.makedirs(session_dir, exist_ok=True) - mock_coder.abs_root_path.side_effect = lambda x: str(tmp_path / x) + _prepare_workspace(mock_coder, tmp_path) + session_dir = Path(tmp_path) / ".cecli" / "sessions" # 1. Save a session with architect mode mock_coder.edit_format = "architect" @@ -157,16 +235,14 @@ def __init__(self, edit_format, **kwargs): # 4. Assert that the SwitchCoderSignal was raised with the correct edit_format assert excinfo.value.edit_format == "architect" finally: - # Restore the original SwitchCoderSignal commands.SwitchCoderSignal = original_switch_coder_signal @pytest.mark.asyncio async def test_load_session_restores_ask_mode(session_manager, mock_coder, tmp_path): """Test that loading a session restores ask mode.""" - session_dir = tmp_path / ".cecli" / "sessions" - os.makedirs(session_dir, exist_ok=True) - mock_coder.abs_root_path.side_effect = lambda x: str(tmp_path / x) + _prepare_workspace(mock_coder, tmp_path) + session_dir = Path(tmp_path) / ".cecli" / "sessions" # 1. Save a session with ask mode mock_coder.edit_format = "ask" @@ -197,16 +273,14 @@ def __init__(self, edit_format, **kwargs): # 4. Assert that the SwitchCoderSignal was raised with the correct edit_format assert excinfo.value.edit_format == "ask" finally: - # Restore the original SwitchCoderSignal commands.SwitchCoderSignal = original_switch_coder_signal @pytest.mark.asyncio async def test_load_session_backwards_compatible(session_manager, mock_coder, tmp_path): """Test that loading an old session (without edit_format) uses current mode.""" - session_dir = tmp_path / ".cecli" / "sessions" - os.makedirs(session_dir, exist_ok=True) - mock_coder.abs_root_path.side_effect = lambda x: str(tmp_path / x) + _prepare_workspace(mock_coder, tmp_path) + session_dir = Path(tmp_path) / ".cecli" / "sessions" # 1. Create a session file without edit_format (old format) session_name = "old_session" @@ -248,16 +322,14 @@ def __init__(self, edit_format, **kwargs): # 4. Assert that the SwitchCoderSignal was raised with the current mode (not None) assert excinfo.value.edit_format == "agent" finally: - # Restore the original SwitchCoderSignal commands.SwitchCoderSignal = original_switch_coder_signal @pytest.mark.asyncio async def test_load_session_with_agent_mode_and_mcp_skills(session_manager, mock_coder, tmp_path): """Test that loading a session with agent mode restores MCP servers and skills.""" - session_dir = tmp_path / ".cecli" / "sessions" - os.makedirs(session_dir, exist_ok=True) - mock_coder.abs_root_path.side_effect = lambda x: str(tmp_path / x) + _prepare_workspace(mock_coder, tmp_path) + session_dir = Path(tmp_path) / ".cecli" / "sessions" # 1. Save a session with agent mode and MCP servers/skills mock_coder.edit_format = "agent" @@ -303,7 +375,6 @@ def __init__(self, edit_format, **kwargs): # 4. Assert that the SwitchCoderSignal was raised with the correct edit_format assert excinfo.value.edit_format == "agent" finally: - # Restore the original SwitchCoderSignal commands.SwitchCoderSignal = original_switch_coder_signal @@ -311,9 +382,8 @@ def __init__(self, edit_format, **kwargs): @pytest.mark.parametrize("edit_format", ["diff", "architect", "ask", "agent"]) def test_save_session_saves_edit_format(session_manager, mock_coder, tmp_path, edit_format): """Test that save_session correctly saves the edit_format for all modes.""" - session_dir = tmp_path / ".cecli" / "sessions" - os.makedirs(session_dir, exist_ok=True) - mock_coder.abs_root_path.side_effect = lambda x: str(tmp_path / x) + _prepare_workspace(mock_coder, tmp_path) + session_dir = Path(tmp_path) / ".cecli" / "sessions" # Set the edit_format mock_coder.edit_format = edit_format @@ -332,3 +402,114 @@ def test_save_session_saves_edit_format(session_manager, mock_coder, tmp_path, e # Verify edit_format was saved correctly assert session_data["edit_format"] == edit_format + + +def test_save_session_encrypted_on_disk(encrypt_coder, session_key32, tmp_path): + session_manager = SessionManager(encrypt_coder, encrypt_coder.io) + _prepare_workspace(encrypt_coder, tmp_path) + session_dir = Path(tmp_path) / ".cecli" / "sessions" + + assert session_manager.save_session("secret", output=False) + path = session_dir / "secret.json" + raw = path.read_bytes() + assert session_crypto.is_encrypted_payload(raw) + data = session_crypto.decrypt_session_bytes(raw, session_key32) + assert data["session_name"] == "secret" + assert data["model"] == "test_model" + + +def test_save_session_encrypt_without_key_fails(mock_coder, monkeypatch, tmp_path): + monkeypatch.delenv(session_crypto.KEY_ENV, raising=False) + _prepare_workspace(mock_coder, tmp_path) + mock_coder.args = SimpleNamespace( + model="test_model", + weak_model="test_weak_model", + editor_model="test_editor_model", + agent_model="test_agent_model", + editor_edit_format="editor-diff", + verbose=False, + session_encrypt=True, + session_key_file=None, + ) + manager = SessionManager(mock_coder, mock_coder.io) + assert manager.save_session("nope", output=False) is False + + +@pytest.mark.asyncio +async def test_load_encrypted_session_switch_false(encrypt_coder, session_key32, tmp_path): + session_manager = SessionManager(encrypt_coder, encrypt_coder.io) + _prepare_workspace(encrypt_coder, tmp_path) + encrypt_coder.edit_format = "ask" + assert session_manager.save_session("enc-load", output=False) + + encrypt_coder.edit_format = "diff" + path = Path(tmp_path) / ".cecli" / "sessions" / "enc-load.json" + applied = await session_manager.load_session(str(path), switch=False) + assert applied is True + # switch=False applies messages/files but leaves edit_format for SwitchCoderSignal path + loaded = session_crypto.decrypt_session_bytes(path.read_bytes(), session_key32) + assert loaded["edit_format"] == "ask" + + +def test_list_sessions_encrypted_with_key(encrypt_coder, tmp_path): + session_manager = SessionManager(encrypt_coder, encrypt_coder.io) + _prepare_workspace(encrypt_coder, tmp_path) + session_manager.save_session("listed", output=False) + + sessions = session_manager.list_sessions() + assert len(sessions) == 1 + assert sessions[0]["name"] == "listed" + assert sessions[0]["model"] == "test_model" + assert sessions[0].get("encrypted") is True + + +def test_list_sessions_encrypted_without_key(encrypt_coder, monkeypatch, tmp_path): + """Encrypted files list as placeholders when CECLI_SESSION_KEY is unset.""" + _prepare_workspace(encrypt_coder, tmp_path) + session_manager = SessionManager(encrypt_coder, encrypt_coder.io) + session_manager.save_session("locked", output=False) + + monkeypatch.delenv(session_crypto.KEY_ENV, raising=False) + encrypt_coder.args = SimpleNamespace( + model="test_model", + weak_model="test_weak_model", + editor_model="test_editor_model", + agent_model="test_agent_model", + editor_edit_format="editor-diff", + verbose=False, + session_encrypt=False, + session_key_file=None, + ) + sessions = session_manager.list_sessions() + assert len(sessions) == 1 + assert sessions[0]["encrypted"] is True + assert sessions[0]["model"] == "encrypted" + + +@pytest.mark.asyncio +async def test_load_encrypted_file_with_env_key_only(encrypt_coder, session_key_env, tmp_path): + """Decrypt on load uses CECLI_SESSION_KEY even when session_encrypt is false on args.""" + _prepare_workspace(encrypt_coder, tmp_path) + encrypt_coder.edit_format = "architect" + SessionManager(encrypt_coder, encrypt_coder.io).save_session("env-load", output=False) + + encrypt_coder.args = SimpleNamespace( + model="test_model", + weak_model="test_weak_model", + editor_model="test_editor_model", + agent_model="test_agent_model", + editor_edit_format="editor-diff", + verbose=False, + session_encrypt=False, + session_key_file=None, + ) + encrypt_coder.edit_format = "diff" + path = Path(tmp_path) / ".cecli" / "sessions" / "env-load.json" + applied = await SessionManager(encrypt_coder, encrypt_coder.io).load_session( + str(path), switch=False + ) + assert applied is True + loaded = session_crypto.decrypt_session_bytes( + path.read_bytes(), session_crypto.resolve_key() + ) + assert loaded["edit_format"] == "architect"