Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai>
Co-authored-by: Paul VEZIA <166131032+le-codeur-rapide@users.noreply.github.com>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Clément Drouin 2026-06-16 10:43:46 +02:00 committed by GitHub
parent c2cb612ac1
commit 564a14365e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 952 additions and 252 deletions

View file

@ -3,4 +3,4 @@ from __future__ import annotations
from pathlib import Path
VIBE_ROOT = Path(__file__).parent
__version__ = "2.16.0"
__version__ = "2.16.1"

View file

@ -10,7 +10,7 @@ import os
from pathlib import Path
import signal
import sys
from typing import Any, Protocol, cast, override
from typing import Any, Literal, Protocol, cast, override
from uuid import uuid4
from acp import (
@ -21,6 +21,7 @@ from acp import (
LoadSessionResponse,
NewSessionResponse,
PromptResponse,
RequestError,
SetSessionModelResponse,
SetSessionModeResponse,
run_agent,
@ -148,6 +149,13 @@ from vibe.core.telemetry.build_metadata import build_entrypoint_metadata
from vibe.core.telemetry.send import TelemetryClient
from vibe.core.telemetry.types import EntrypointMetadata
from vibe.core.tools.permissions import RequiredPermission
from vibe.core.trusted_folders import (
WorkspaceTrustDecision,
WorkspaceTrustPrompt,
apply_workspace_trust_decision,
available_workspace_trust_decisions,
maybe_build_workspace_trust_prompt,
)
from vibe.core.types import (
AgentProfileChangedEvent,
ApprovalCallback,
@ -193,6 +201,8 @@ logger = logging.getLogger("vibe")
NON_INTERACTIVE_DISABLED_TOOLS = ["ask_user_question", "exit_plan_mode"]
INITIAL_AVAILABLE_COMMANDS_DELAY_SECONDS = 0.1
WORKSPACE_TRUST_CAPABILITY = "workspace-trust"
TRUST_REQUEST_METHOD = "trust/request"
def _merge_non_interactive_disabled_tools(config: VibeConfig) -> None:
@ -232,6 +242,24 @@ class TelemetrySendNotification(BaseModel):
session_id: str = Field(validation_alias=AliasChoices("session_id", "sessionId"))
class WorkspaceTrustRequest(BaseModel):
model_config = ConfigDict(extra="forbid", populate_by_name=True)
cwd: str
repo_root: str | None = Field(default=None, alias="repoRoot")
detected_files: list[str] = Field(alias="detectedFiles")
repo_detected_files: list[str] = Field(alias="repoDetectedFiles")
available_decisions: list[WorkspaceTrustDecision] = Field(
alias="availableDecisions"
)
class WorkspaceTrustResponse(BaseModel):
model_config = ConfigDict(extra="forbid")
decision: WorkspaceTrustDecision | Literal["cancelled"]
class AuthStatusResponse(BaseModel):
model_config = ConfigDict(extra="forbid", populate_by_name=True)
@ -407,6 +435,14 @@ class VibeAcpAgentLoop(AcpAgent):
is True
)
def _client_supports_workspace_trust(self) -> bool:
return bool(
self.client_capabilities
and self.client_capabilities.field_meta
and self.client_capabilities.field_meta.get(WORKSPACE_TRUST_CAPABILITY)
is True
)
@override
async def initialize(
self,
@ -699,6 +735,55 @@ class VibeAcpAgentLoop(AcpAgent):
)
return modes_state, modes_config, models_state, models_config
def _build_workspace_trust_request(
self, prompt: WorkspaceTrustPrompt
) -> WorkspaceTrustRequest:
return WorkspaceTrustRequest(
cwd=str(prompt.cwd.resolve()),
repoRoot=str(prompt.repo_root.resolve())
if prompt.offer_repo_trust and prompt.repo_root
else None,
detectedFiles=prompt.detected_files,
repoDetectedFiles=prompt.repo_detected_files,
availableDecisions=available_workspace_trust_decisions(
prompt, include_session=True
),
)
async def _resolve_workspace_trust(self, cwd: Path) -> None:
if not self._client_supports_workspace_trust():
return
prompt = maybe_build_workspace_trust_prompt(cwd)
if prompt is None:
return
request = self._build_workspace_trust_request(prompt)
try:
raw_response = await self.client.ext_method(
TRUST_REQUEST_METHOD, request.model_dump(mode="json", by_alias=True)
)
except RequestError as exc:
if exc.code == NotImplementedMethodError.code:
return
raise
try:
response = WorkspaceTrustResponse.model_validate(raw_response)
except ValidationError as exc:
raise InvalidRequestError(
f"Invalid ACP trust decision response: {exc}"
) from exc
if response.decision == "cancelled":
raise InvalidRequestError("Workspace trust prompt was cancelled.")
try:
apply_workspace_trust_decision(prompt, response.decision)
except ValueError as exc:
raise InvalidRequestError(str(exc)) from exc
@override
async def new_session(
self,
@ -709,6 +794,7 @@ class VibeAcpAgentLoop(AcpAgent):
) -> NewSessionResponse:
load_dotenv_values()
os.chdir(cwd)
await self._resolve_workspace_trust(Path.cwd())
config = self._load_config()
hook_config_result = load_hooks_from_fs(config)
@ -973,6 +1059,7 @@ class VibeAcpAgentLoop(AcpAgent):
) -> LoadSessionResponse | None:
load_dotenv_values()
os.chdir(cwd)
await self._resolve_workspace_trust(Path.cwd())
config = self._load_config()
hook_config_result = load_hooks_from_fs(config)

View file

@ -292,7 +292,12 @@ def _maybe_run_startup_update_prompt(
)
sys.exit(0)
case UpdatePromptResult.UPDATE_FAILED:
rprint("[red]✗ Vibe could not be updated automatically.[/]")
rprint(
"[yellow]Vibe could not update automatically.[/]\n"
" Update manually with your package manager (for example "
"[bold]uv tool upgrade mistral-vibe[/]), or keep using "
f"the current version ({__version__}) for now."
)
sys.exit(1)

View file

@ -10,13 +10,11 @@ from rich import print as rprint
from vibe import __version__
from vibe.core.config.harness_files import init_harness_files_manager
from vibe.core.trusted_folders import (
find_git_repo_ancestor,
find_repo_trustable_files_for_cwd,
find_trustable_files,
apply_workspace_trust_decision,
maybe_build_workspace_trust_prompt,
trusted_folders_manager,
)
from vibe.setup.trusted_folders.trust_folder_dialog import (
TrustDecision,
TrustDialogQuitException,
ask_trust_folder,
)
@ -157,38 +155,18 @@ def parse_arguments() -> argparse.Namespace:
def check_and_resolve_trusted_folder(cwd: Path) -> None:
if cwd.resolve() == Path.home().resolve():
prompt = maybe_build_workspace_trust_prompt(cwd)
if prompt is None:
return
if trusted_folders_manager.is_trusted(cwd) is True:
return
if trusted_folders_manager.is_explicitly_untrusted(cwd):
return
repo_root = find_git_repo_ancestor(cwd)
detected_files = find_trustable_files(cwd)
repo_detected_files = find_repo_trustable_files_for_cwd(cwd, repo_root)
if not detected_files and not repo_detected_files:
return
offer_repo_trust = (
repo_root is not None
and trusted_folders_manager.is_trusted(repo_root) is not True
and not trusted_folders_manager.is_explicitly_untrusted(repo_root)
)
repo_explicitly_untrusted = (
repo_root is not None
and trusted_folders_manager.is_explicitly_untrusted(repo_root)
)
try:
decision = ask_trust_folder(
cwd,
repo_root,
detected_files,
repo_detected_files=repo_detected_files,
offer_repo_trust=offer_repo_trust,
repo_explicitly_untrusted=repo_explicitly_untrusted,
prompt.cwd,
prompt.repo_root,
prompt.detected_files,
repo_detected_files=prompt.repo_detected_files,
offer_repo_trust=prompt.offer_repo_trust,
repo_explicitly_untrusted=prompt.repo_explicitly_untrusted,
)
except (KeyboardInterrupt, EOFError, TrustDialogQuitException):
sys.exit(0)
@ -196,13 +174,8 @@ def check_and_resolve_trusted_folder(cwd: Path) -> None:
rprint(f"[yellow]Error showing trust dialog: {e}[/]")
return
match decision:
case TrustDecision.TRUST_REPO if repo_root is not None:
trusted_folders_manager.add_trusted(repo_root)
case TrustDecision.TRUST_CWD:
trusted_folders_manager.add_trusted(cwd)
case TrustDecision.DECLINE:
trusted_folders_manager.add_untrusted(cwd)
if decision is not None:
apply_workspace_trust_decision(prompt, decision)
def main() -> None:

View file

@ -3,7 +3,7 @@ from __future__ import annotations
import asyncio
import codecs
from collections.abc import AsyncGenerator
from contextlib import aclosing
from contextlib import aclosing, suppress
from dataclasses import dataclass
from enum import StrEnum, auto
import gc
@ -170,10 +170,12 @@ from vibe.core.paths import HISTORY_FILE
from vibe.core.rewind import RewindError
from vibe.core.session.image_snapshot import ImageSnapshotError, snapshot_image
from vibe.core.session.resume_sessions import (
RemoteResumeResult,
RemoteResumeSessions,
ResumeSessionInfo,
can_delete_resume_session_source,
list_local_resume_sessions,
list_remote_resume_sessions,
session_latest_messages,
short_session_id,
)
from vibe.core.session.saved_sessions import (
@ -428,6 +430,8 @@ class VibeApp(App): # noqa: PLR0904
self._bash_task: asyncio.Task | None = None
self._queue = QueueController(self._build_queue_ports())
self._remote_manager = RemoteSessionManager()
self._remote_resume = RemoteResumeSessions(lambda: self.config)
self._resume_merge_task: asyncio.Task[None] | None = None
self._loading_widget: LoadingWidget | None = None
self._pending_approval: asyncio.Future | None = None
@ -2216,29 +2220,71 @@ class VibeApp(App): # noqa: PLR0904
UserCommandMessage(f'Session renamed to "{renamed_title}".')
)
async def _show_session_picker(self, **kwargs: Any) -> None:
cwd = str(Path.cwd())
local_sessions = (
list_local_resume_sessions(self.config, cwd)
if self.config.session_logging.enabled
else []
def _build_picker(self, sessions: list[ResumeSessionInfo]) -> SessionPickerApp:
sessions = sorted(sessions, key=lambda s: s.end_time or "", reverse=True)
return SessionPickerApp(
sessions=sessions,
latest_messages=session_latest_messages(sessions, self.config),
current_session_id=self.agent_loop.session_id,
cwd=str(Path.cwd()),
)
async def _merge_remote_into_picker(
self, picker: SessionPickerApp, remote_task: asyncio.Task[RemoteResumeResult]
) -> None:
remote_sessions, remote_error = await remote_task
if not picker.is_mounted:
return
if remote_error is not None:
await self._mount_and_scroll(
ErrorMessage(remote_error, collapsed=self._tools_collapsed)
)
if remote_sessions:
picker.add_sessions(
remote_sessions, session_latest_messages(remote_sessions, self.config)
)
async def _cancel_resume_merge(self) -> None:
"""Cancel the task that fetch and merges remote sessions into the picker, if it exists."""
if self._resume_merge_task is not None and not self._resume_merge_task.done():
self._resume_merge_task.cancel()
with suppress(asyncio.CancelledError):
await self._resume_merge_task
self._resume_merge_task = None
async def _close_remote_resume(self) -> None:
"""Close the remote resume connection and cancel any ongoing merge task.
Used to gracefully close the connection when the app is exiting
"""
await self._cancel_resume_merge()
await self._remote_resume.aclose()
async def _show_session_picker(self, **kwargs: Any) -> None:
await self._cancel_resume_merge()
remote_list_timeout = max(float(self.config.api_timeout), 10.0)
remote_error: str | None = None
remote_task = self._remote_resume.start(remote_list_timeout)
# If there are no local sessions, show the remote picker directly
if not self.config.session_logging.enabled or not (
local_sessions := list_local_resume_sessions(self.config, str(Path.cwd()))
):
await self._show_session_picker_remote_only(remote_task)
return
picker = self._build_picker(local_sessions)
await self._switch_from_input(picker)
self._resume_merge_task = asyncio.create_task(
self._merge_remote_into_picker(picker, remote_task)
)
async def _show_session_picker_remote_only(
self, remote_task: asyncio.Task[RemoteResumeResult]
) -> None:
await self._ensure_loading_widget("Loading sessions")
try:
remote_sessions = await asyncio.wait_for(
list_remote_resume_sessions(self.config), timeout=remote_list_timeout
)
except TimeoutError:
remote_sessions = []
remote_error = (
"Timed out while listing remote sessions "
f"after {remote_list_timeout:.0f}s."
)
except Exception as e:
remote_sessions = []
remote_error = f"Failed to list remote sessions: {e}"
remote_sessions, remote_error = await remote_task
except asyncio.CancelledError:
return
finally:
await self._remove_loading_widget()
@ -2247,37 +2293,13 @@ class VibeApp(App): # noqa: PLR0904
ErrorMessage(remote_error, collapsed=self._tools_collapsed)
)
raw_sessions = [*local_sessions, *remote_sessions]
if not raw_sessions:
if not remote_sessions:
await self._mount_and_scroll(
UserCommandMessage("No sessions found for this directory.")
)
return
sessions = sorted(raw_sessions, key=lambda s: s.end_time or "", reverse=True)
latest_messages = {
s.option_id: s.title
or SessionLoader.get_first_user_message(
s.session_id, self.config.session_logging
)
for s in sessions
if s.source == "local"
}
for session in sessions:
if session.source == "remote":
latest_messages[session.option_id] = (
f"{session.title or 'Remote workflow'} ({(session.status or 'RUNNING').lower()})"
)
picker = SessionPickerApp(
sessions=sessions,
latest_messages=latest_messages,
current_session_id=self.agent_loop.session_id,
cwd=cwd,
)
await self._switch_from_input(picker)
await self._switch_from_input(self._build_picker(remote_sessions))
async def on_session_picker_app_session_selected(
self, event: SessionPickerApp.SessionSelected
@ -2686,6 +2708,7 @@ class VibeApp(App): # noqa: PLR0904
self._log_reader.shutdown()
await self._voice_manager.close()
await self._narrator_manager.close()
await self._close_remote_resume()
await self.agent_loop.aclose()
try:
await self.agent_loop.telemetry_client.aclose()
@ -3437,6 +3460,7 @@ class VibeApp(App): # noqa: PLR0904
self._log_reader.shutdown()
self._narrator_manager.cancel()
await self._close_remote_resume()
await self.agent_loop.aclose()
try:
await self.agent_loop.telemetry_client.aclose()

View file

@ -168,6 +168,18 @@ class SessionPickerApp(Container):
def _normal_option_text(self, session: ResumeSessionInfo) -> Text:
return _build_option_text(session, self._session_message(session))
def _option_text(self, session: ResumeSessionInfo) -> Text:
state = self._delete_state
if state is None or state.option_id != session.option_id:
return self._normal_option_text(session)
match state.kind:
case "confirmation":
return self._delete_confirmation_option_text(session)
case "feedback":
return self._delete_feedback_option_text(session)
case "pending":
return self._delete_pending_option_text(session)
def _delete_confirmation_option_text(self, session: ResumeSessionInfo) -> Text:
text = _build_option_text(session, "")
text.append("Press D again to delete")
@ -239,6 +251,41 @@ class SessionPickerApp(Container):
self._option_list().remove_option(option_id)
return True
def add_sessions(
self, sessions: list[ResumeSessionInfo], latest_messages: dict[str, str]
) -> None:
existing = {s.option_id for s in self._sessions}
new_sessions = [s for s in sessions if s.option_id not in existing]
if not new_sessions:
return
self._sessions = sorted(
[*self._sessions, *new_sessions],
key=lambda s: s.end_time or "",
reverse=True,
)
self._latest_messages.update(latest_messages)
option_list = self._option_list()
highlighted = self._highlighted_option_id()
option_list.clear_options()
option_list.add_options([
Option(self._option_text(session), id=session.option_id)
for session in self._sessions
])
self._refresh_header()
if highlighted is None:
return
for index, session in enumerate(self._sessions):
if session.option_id == highlighted:
option_list.highlighted = index
return
def _refresh_header(self) -> None:
has_remote = any(session.source == "remote" for session in self._sessions)
header = self.query_one(".sessionpicker-header", NoMarkupStatic)
header.update(_build_header_text(self._cwd, has_remote))
def clear_pending_delete(self, option_id: str) -> bool:
if not self._delete_state_matches(option_id, "pending"):
return False

View file

@ -45,6 +45,9 @@ class WorkflowsClient:
exc_val: BaseException | None,
exc_tb: Any,
) -> None:
await self.aclose()
async def aclose(self) -> None:
if self._owns_client and self._client:
await self._client.aclose()
self._client = None

View file

@ -85,7 +85,7 @@ class RemoteEventsSource:
async def close(self) -> None:
if self._client is not None:
await self._client.__aexit__(None, None, None)
await self._client.aclose()
self._client = None
async def attach(self) -> AsyncGenerator[BaseEvent, None]:

View file

@ -1,13 +1,19 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable, Sequence
import contextlib
from dataclasses import dataclass
from datetime import datetime
from typing import Literal
from typing import Literal, NamedTuple, Protocol
from vibe.core.config import VibeConfig
from vibe.core.logger import logger
from vibe.core.nuage.client import WorkflowsClient
from vibe.core.nuage.workflow import WorkflowExecutionStatus
from vibe.core.nuage.workflow import (
WorkflowExecutionListResponse,
WorkflowExecutionStatus,
)
from vibe.core.session.session_id import shorten_session_id
from vibe.core.session.session_loader import SessionLoader
@ -28,6 +34,21 @@ _ACTIVE_STATUSES = [
]
class RemoteWorkflowRunsClient(Protocol):
async def get_workflow_runs(
self,
workflow_identifier: str | None = None,
page_size: int = 50,
next_page_token: str | None = None,
status: Sequence[WorkflowExecutionStatus] | None = None,
user_id: str = "current",
) -> WorkflowExecutionListResponse: ...
class RemoteResumeClient(RemoteWorkflowRunsClient, Protocol):
async def aclose(self) -> None: ...
@dataclass(frozen=True)
class ResumeSessionInfo:
session_id: str
@ -61,21 +82,12 @@ def list_local_resume_sessions(
]
async def list_remote_resume_sessions(config: VibeConfig) -> list[ResumeSessionInfo]:
if not config.vibe_code_enabled or not config.vibe_code_api_key:
logger.debug("Remote resume listing skipped: missing Vibe Code configuration")
return []
async with WorkflowsClient(
base_url=config.vibe_code_base_url,
api_key=config.vibe_code_api_key,
timeout=config.api_timeout,
) as client:
response = await client.get_workflow_runs(
workflow_identifier=config.vibe_code_workflow_id,
page_size=50,
status=_ACTIVE_STATUSES,
)
async def list_remote_resume_sessions(
client: RemoteWorkflowRunsClient, workflow_id: str
) -> list[ResumeSessionInfo]:
response = await client.get_workflow_runs(
workflow_identifier=workflow_id, page_size=50, status=_ACTIVE_STATUSES
)
seen: dict[str, ResumeSessionInfo] = {}
latest_start: dict[str, datetime] = {}
@ -101,3 +113,111 @@ async def list_remote_resume_sessions(config: VibeConfig) -> list[ResumeSessionI
logger.debug("Remote resume listing filtered sessions: %d", len(sessions))
return sessions
def session_latest_messages(
sessions: list[ResumeSessionInfo], config: VibeConfig
) -> dict[str, str]:
messages: dict[str, str] = {}
for session in sessions:
if session.source == "remote":
status = (session.status or "RUNNING").lower()
messages[session.option_id] = (
f"{session.title or 'Remote workflow'} ({status})"
)
continue
messages[session.option_id] = (
session.title
or SessionLoader.get_first_user_message(
session.session_id, config.session_logging
)
)
return messages
class RemoteResumeResult(NamedTuple):
sessions: list[ResumeSessionInfo]
error: str | None
def _default_remote_resume_client(config: VibeConfig) -> RemoteResumeClient:
return WorkflowsClient(
base_url=config.vibe_code_base_url,
api_key=config.vibe_code_api_key,
timeout=config.api_timeout,
)
class RemoteResumeSessions:
def __init__(
self,
get_config: Callable[[], VibeConfig],
client_factory: Callable[
[VibeConfig], RemoteResumeClient
] = _default_remote_resume_client,
) -> None:
self._get_config = get_config
self._client_factory = client_factory
self._client: RemoteResumeClient | None = None
self._client_settings: tuple[str, str, float] | None = None
self._fetch_task: asyncio.Task[RemoteResumeResult] | None = None
async def _reusable_client(self, config: VibeConfig) -> RemoteResumeClient:
settings = (
config.vibe_code_base_url,
config.vibe_code_api_key,
config.api_timeout,
)
if self._client is not None and self._client_settings == settings:
return self._client
if self._client is not None:
await self._close_client()
self._client = self._client_factory(config)
self._client_settings = settings
return self._client
def start(self, timeout: float) -> asyncio.Task[RemoteResumeResult]:
if self._fetch_task is not None and not self._fetch_task.done():
self._fetch_task.cancel()
self._fetch_task = asyncio.create_task(self.fetch(timeout))
return self._fetch_task
async def fetch(self, timeout: float) -> RemoteResumeResult:
config = self._get_config()
if not config.vibe_code_enabled or not config.vibe_code_api_key:
logger.debug(
"Remote resume listing skipped: missing Vibe Code configuration"
)
return RemoteResumeResult([], None)
try:
client = await self._reusable_client(config)
sessions = await asyncio.wait_for(
list_remote_resume_sessions(client, config.vibe_code_workflow_id),
timeout=timeout,
)
except TimeoutError:
return RemoteResumeResult(
[], f"Timed out while listing remote sessions after {timeout:.0f}s."
)
except Exception as e:
return RemoteResumeResult([], f"Failed to list remote sessions: {e}")
return RemoteResumeResult(sessions, None)
async def aclose(self) -> None:
if self._fetch_task is not None and not self._fetch_task.done():
self._fetch_task.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await self._fetch_task
self._fetch_task = None
await self._close_client()
async def _close_client(self) -> None:
if self._client is None:
return
try:
await self._client.aclose()
except Exception as exc:
logger.error("Failed to close resume workflows client", exc_info=exc)
finally:
self._client = None
self._client_settings = None

View file

@ -26,39 +26,57 @@ class SessionInfo(TypedDict):
class SessionLoader:
@staticmethod
def _is_valid_session( # noqa: PLR0911
def _parse_message_lines(text: str) -> list[dict[str, Any]] | None:
lines = text.split("\n")
if lines and lines[-1] == "":
lines.pop()
messages: list[dict[str, Any]] = []
for line in lines:
message = json.loads(line)
if not isinstance(message, dict):
return None
messages.append(message)
return messages or None
@staticmethod
def _read_validated_session(
session_dir: Path, working_directory: Path | None = None
) -> bool:
"""Check if a session directory contains valid metadata and messages."""
) -> dict[str, Any] | None:
metadata_path = session_dir / METADATA_FILENAME
messages_path = session_dir / MESSAGES_FILENAME
if not metadata_path.is_file() or not messages_path.is_file():
return False
return None
try:
metadata = json.loads(read_safe(metadata_path).text)
if not isinstance(metadata, dict):
return False
return None
if working_directory is not None:
session_working_directory = (metadata.get("environment") or {}).get(
"working_directory"
)
if session_working_directory != str(working_directory):
return False
return None
has_messages = False
for line in read_safe(messages_path).text.splitlines():
has_messages = True
message = json.loads(line)
if not isinstance(message, dict):
return False
if not has_messages:
return False
messages = SessionLoader._parse_message_lines(read_safe(messages_path).text)
except (OSError, json.JSONDecodeError):
return False
return None
return True
if messages is None:
return None
return metadata
@staticmethod
def _is_valid_session(
session_dir: Path, working_directory: Path | None = None
) -> bool:
return (
SessionLoader._read_validated_session(session_dir, working_directory)
is not None
)
@staticmethod
def latest_session(
@ -158,13 +176,8 @@ class SessionLoader:
sessions: list[SessionInfo] = []
for session_dir in session_dirs:
if not SessionLoader._is_valid_session(session_dir):
continue
metadata_path = session_dir / METADATA_FILENAME
try:
metadata = json.loads(read_safe(metadata_path).text)
except (OSError, json.JSONDecodeError):
metadata = SessionLoader._read_validated_session(session_dir)
if metadata is None:
continue
session_id = metadata.get("session_id")

View file

@ -1,5 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from pathlib import Path
import tomllib
@ -13,6 +15,23 @@ from vibe.core.paths import (
)
class WorkspaceTrustDecision(StrEnum):
TRUST_REPO = "trust_repo"
TRUST_CWD = "trust_cwd"
TRUST_SESSION = "trust_session"
DECLINE = "decline"
@dataclass(frozen=True)
class WorkspaceTrustPrompt:
cwd: Path
repo_root: Path | None
detected_files: list[str]
repo_detected_files: list[str]
offer_repo_trust: bool
repo_explicitly_untrusted: bool
def has_agents_md_file(path: Path) -> bool:
agents_md = path / AGENTS_MD_FILENAME
try:
@ -91,6 +110,73 @@ def find_repo_trustable_files_for_cwd(cwd: Path, repo_root: Path | None) -> list
return sorted(found)
def maybe_build_workspace_trust_prompt(cwd: Path) -> WorkspaceTrustPrompt | None:
resolved_cwd = cwd.resolve()
if resolved_cwd == Path.home().resolve():
return None
if trusted_folders_manager.is_trusted(cwd) is True:
return None
if trusted_folders_manager.is_explicitly_untrusted(cwd):
return None
repo_root = find_git_repo_ancestor(cwd)
detected_files = find_trustable_files(cwd)
repo_detected_files = find_repo_trustable_files_for_cwd(cwd, repo_root)
if not detected_files and not repo_detected_files:
return None
resolved_repo_root = repo_root.resolve() if repo_root else None
offer_repo_trust = (
resolved_repo_root is not None
and resolved_repo_root in resolved_cwd.parents
and trusted_folders_manager.is_trusted(resolved_repo_root) is not True
and not trusted_folders_manager.is_explicitly_untrusted(resolved_repo_root)
)
repo_explicitly_untrusted = (
resolved_repo_root is not None
and trusted_folders_manager.is_explicitly_untrusted(resolved_repo_root)
)
return WorkspaceTrustPrompt(
cwd=cwd,
repo_root=resolved_repo_root,
detected_files=detected_files,
repo_detected_files=repo_detected_files,
offer_repo_trust=offer_repo_trust,
repo_explicitly_untrusted=repo_explicitly_untrusted,
)
def available_workspace_trust_decisions(
prompt: WorkspaceTrustPrompt, *, include_session: bool = False
) -> list[WorkspaceTrustDecision]:
decisions = [WorkspaceTrustDecision.TRUST_CWD, WorkspaceTrustDecision.DECLINE]
if include_session:
decisions.insert(1, WorkspaceTrustDecision.TRUST_SESSION)
if prompt.offer_repo_trust:
decisions.insert(0, WorkspaceTrustDecision.TRUST_REPO)
return decisions
def apply_workspace_trust_decision(
prompt: WorkspaceTrustPrompt, decision: WorkspaceTrustDecision
) -> None:
match decision:
case WorkspaceTrustDecision.TRUST_REPO if (
prompt.offer_repo_trust and prompt.repo_root is not None
):
trusted_folders_manager.add_trusted(prompt.repo_root)
case WorkspaceTrustDecision.TRUST_CWD:
trusted_folders_manager.add_trusted(prompt.cwd)
case WorkspaceTrustDecision.TRUST_SESSION:
trusted_folders_manager.trust_for_session(prompt.cwd)
case WorkspaceTrustDecision.DECLINE:
trusted_folders_manager.add_untrusted(prompt.cwd)
case _:
raise ValueError(f"Unsupported trust decision: {decision}")
class TrustedFoldersManager:
def __init__(self) -> None:
self._file_path = TRUSTED_FOLDERS_FILE.path

View file

@ -93,6 +93,9 @@ def _get_candidate_encodings(
def normalize_newlines(text: str) -> tuple[str, str]:
r"""Return ``text`` with ``\n`` newlines and the detected original style."""
if "\r" not in text:
newline = "\n" if "\n" in text else os.linesep
return text, newline
newline = _detect_newline(text)
return text.replace("\r\n", "\n").replace("\r", "\n"), newline

View file

@ -1,6 +1,5 @@
from __future__ import annotations
from enum import Enum
from pathlib import Path
from typing import Any, ClassVar
@ -19,18 +18,14 @@ from textual.widgets import Static
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.core.paths import TRUSTED_FOLDERS_FILE
from vibe.core.trusted_folders import WorkspaceTrustDecision
class TrustDialogQuitException(Exception):
pass
class TrustDecision(Enum):
"""User's choice from the trust dialog."""
TRUST_REPO = "trust_repo"
TRUST_CWD = "trust_cwd"
DECLINE = "decline"
TrustDecision = WorkspaceTrustDecision
class TrustFolderDialog(CenterMiddle):