diff --git a/backend/web/routers/debug.py b/backend/web/routers/debug.py index 57299f219..3dd58564b 100644 --- a/backend/web/routers/debug.py +++ b/backend/web/routers/debug.py @@ -1,5 +1,8 @@ """Debug logging endpoints.""" +import tempfile +from pathlib import Path + from fastapi import APIRouter from pydantic import BaseModel @@ -11,9 +14,15 @@ class LogMessage(BaseModel): timestamp: str +def _debug_log_path() -> Path: + return Path(tempfile.gettempdir()) / "leon-frontend-console.log" + + @router.post("/log") async def log_frontend_message(payload: LogMessage) -> dict: """Receive frontend console logs and write to file.""" - with open("/tmp/leon-frontend-console.log", "a") as f: + log_path = _debug_log_path() + log_path.parent.mkdir(parents=True, exist_ok=True) + with log_path.open("a", encoding="utf-8") as f: f.write(f"[{payload.timestamp}] {payload.message}\n") return {"status": "ok"} diff --git a/core/tools/command/bash/executor.py b/core/tools/command/bash/executor.py index d559970d0..7018326e7 100644 --- a/core/tools/command/bash/executor.py +++ b/core/tools/command/bash/executor.py @@ -23,6 +23,14 @@ def __init__(self, default_cwd: str | None = None): self._session_lock = asyncio.Lock() self._current_cwd = default_cwd or os.getcwd() + @staticmethod + def _unsupported_on_windows_message() -> str: + return "BashExecutor is not supported on Windows. Use PowerShellExecutor instead." + + def _ensure_supported(self) -> None: + if os.name == "nt": + raise RuntimeError(self._unsupported_on_windows_message()) + async def _ensure_session(self, env: dict[str, str]) -> asyncio.subprocess.Process: """Ensure persistent shell session exists.""" if self._session is None or self._session.returncode is not None: @@ -74,6 +82,12 @@ async def execute( timeout: float | None = None, env: dict[str, str] | None = None, ) -> ExecuteResult: + if os.name == "nt": + return ExecuteResult( + exit_code=127, + stdout="", + stderr=self._unsupported_on_windows_message(), + ) work_dir = cwd or self.default_cwd or os.getcwd() merged_env = os.environ.copy() @@ -119,6 +133,7 @@ async def execute_async( cwd: str | None = None, env: dict[str, str] | None = None, ) -> AsyncCommand: + self._ensure_supported() work_dir = cwd or self.default_cwd or os.getcwd() command_id = f"cmd_{uuid.uuid4().hex[:12]}" diff --git a/core/tools/command/zsh/executor.py b/core/tools/command/zsh/executor.py index 6990531aa..429bd32a8 100644 --- a/core/tools/command/zsh/executor.py +++ b/core/tools/command/zsh/executor.py @@ -23,6 +23,14 @@ def __init__(self, default_cwd: str | None = None): self._session_lock = asyncio.Lock() self._current_cwd = default_cwd or os.getcwd() + @staticmethod + def _unsupported_on_windows_message() -> str: + return "ZshExecutor is not supported on Windows. Use PowerShellExecutor instead." + + def _ensure_supported(self) -> None: + if os.name == "nt": + raise RuntimeError(self._unsupported_on_windows_message()) + async def _ensure_session(self, env: dict[str, str]) -> asyncio.subprocess.Process: """Ensure persistent shell session exists.""" if self._session is None or self._session.returncode is not None: @@ -74,6 +82,12 @@ async def execute( timeout: float | None = None, env: dict[str, str] | None = None, ) -> ExecuteResult: + if os.name == "nt": + return ExecuteResult( + exit_code=127, + stdout="", + stderr=self._unsupported_on_windows_message(), + ) work_dir = cwd or self.default_cwd or os.getcwd() merged_env = os.environ.copy() @@ -119,6 +133,7 @@ async def execute_async( cwd: str | None = None, env: dict[str, str] | None = None, ) -> AsyncCommand: + self._ensure_supported() work_dir = cwd or self.default_cwd or os.getcwd() command_id = f"cmd_{uuid.uuid4().hex[:12]}" diff --git a/sandbox/capability.py b/sandbox/capability.py index 4b278742a..26b8d6b56 100644 --- a/sandbox/capability.py +++ b/sandbox/capability.py @@ -21,6 +21,10 @@ from sandbox.manager import SandboxManager +def _ps_quote(value: str) -> str: + return "'" + value.replace("'", "''") + "'" + + class SandboxCapability: """Agent-facing capability object. @@ -79,15 +83,25 @@ def __init__(self, session: ChatSession, manager: SandboxManager | None = None): db_path = getattr(session.terminal, "db_path", None) self._db_path: Path | None = Path(db_path) if db_path else None + def _uses_windows_shell(self) -> bool: + return bool(getattr(self._session.runtime, "_use_windows_shell", False)) + def _wrap_command(self, command: str, cwd: str | None, env: dict[str, str] | None) -> tuple[str, str]: wrapped = command + windows_shell = self._uses_windows_shell() if env: - exports = "\n".join(f"export {k}={shlex.quote(v)}" for k, v in env.items()) + if windows_shell: + exports = "\n".join(f"$env:{k} = {_ps_quote(str(v))}" for k, v in env.items()) + else: + exports = "\n".join(f"export {k}={shlex.quote(str(v))}" for k, v in env.items()) wrapped = f"{exports}\n{wrapped}" # @@@runtime-owned-cwd - Preserve runtime session cwd unless caller explicitly requests cwd override. work_dir = cwd or self._session.terminal.get_state().cwd if cwd: - wrapped = f"cd {shlex.quote(cwd)}\n{wrapped}" + if windows_shell: + wrapped = f"Set-Location -LiteralPath {_ps_quote(cwd)}\n{wrapped}" + else: + wrapped = f"cd {shlex.quote(cwd)}\n{wrapped}" return wrapped, work_dir async def execute(self, command: str, cwd: str | None = None, timeout: float | None = None, env: dict[str, str] | None = None): diff --git a/sandbox/providers/local.py b/sandbox/providers/local.py index a8c6c6f02..a4b713d5f 100644 --- a/sandbox/providers/local.py +++ b/sandbox/providers/local.py @@ -259,6 +259,7 @@ def _build_windows_shell_script( lines = [ "$ErrorActionPreference = 'Continue'", f"Set-Location -LiteralPath {_ps_quote(cwd)}", + "$leonErrorCount = $Error.Count", ] for key, value in env_delta.items(): lines.append(f"$env:{key} = {_ps_quote(str(value))}") @@ -268,7 +269,9 @@ def _build_windows_shell_script( command, "}", "$leonExit = $LASTEXITCODE", - "if ($null -eq $leonExit) { $leonExit = 0 }", + "if ($null -eq $leonExit -or $leonExit -eq 0) {", + " if ($Error.Count -gt $leonErrorCount) { $leonExit = 1 } else { $leonExit = 0 }", + "}", f"Write-Output {_ps_quote(start)}", "(Get-Location).Path", 'Get-ChildItem Env: | ForEach-Object { "{0}={1}" -f $_.Name, $_.Value }', diff --git a/sandbox/terminal.py b/sandbox/terminal.py index f298f3aba..12355294b 100644 --- a/sandbox/terminal.py +++ b/sandbox/terminal.py @@ -42,6 +42,45 @@ def _connect(db_path: Path) -> sqlite3.Connection: return connect_sqlite(db_path) +_REMOTE_PROVIDER_DEFAULT_CWDS = { + "agentbay": "/home/wuying", + "daytona": "/home/daytona", + "docker": "/workspace", + "e2b": "/home/user", +} + + +def _host_terminal_cwd() -> str: + try: + return str(Path.cwd()) + except OSError: + return str(Path.home()) + + +def lease_provider_name(lease_id: str | None, db_path: Path) -> str | None: + if not lease_id: + return None + try: + with _connect(db_path) as conn: + row = conn.execute( + "SELECT provider_name FROM sandbox_leases WHERE lease_id = ?", + (lease_id,), + ).fetchone() + except sqlite3.Error: + return None + if not row or not row[0]: + return None + return str(row[0]) + + +def default_terminal_cwd(provider_name: str | None = None) -> str: + if provider_name == "local": + return _host_terminal_cwd() + if provider_name: + return _REMOTE_PROVIDER_DEFAULT_CWDS.get(provider_name, "/home/user") + return _host_terminal_cwd() + + @dataclass class TerminalState: """Terminal state snapshot. @@ -162,8 +201,9 @@ def _persist_state(self) -> None: def terminal_from_row(row: dict, db_path: Path) -> AbstractTerminal: """Construct SQLiteTerminal from a repo dict.""" + provider_name = lease_provider_name(row.get("lease_id"), db_path) state = TerminalState( - cwd=row.get("cwd", "/root"), + cwd=row.get("cwd") or default_terminal_cwd(provider_name=provider_name), env_delta=json.loads(row.get("env_delta_json", "{}")), state_version=int(row.get("state_version", 0)), ) diff --git a/storage/contracts.py b/storage/contracts.py index fef514943..d17059a29 100644 --- a/storage/contracts.py +++ b/storage/contracts.py @@ -39,7 +39,7 @@ def get_by_id(self, terminal_id: str) -> dict[str, Any] | None: ... def get_latest_by_lease(self, lease_id: str) -> dict[str, Any] | None: ... def get_timestamps(self, terminal_id: str) -> tuple[str | None, str | None]: ... def list_by_thread(self, thread_id: str) -> list[dict[str, Any]]: ... - def create(self, terminal_id: str, thread_id: str, lease_id: str, initial_cwd: str = "/root") -> dict[str, Any]: ... + def create(self, terminal_id: str, thread_id: str, lease_id: str, initial_cwd: str | None = None) -> dict[str, Any]: ... def set_active(self, thread_id: str, terminal_id: str) -> None: ... def delete_by_thread(self, thread_id: str) -> None: ... def delete(self, terminal_id: str) -> None: ... diff --git a/storage/providers/sqlite/terminal_repo.py b/storage/providers/sqlite/terminal_repo.py index de8fd90e0..4fce15f8a 100644 --- a/storage/providers/sqlite/terminal_repo.py +++ b/storage/providers/sqlite/terminal_repo.py @@ -11,6 +11,8 @@ from sandbox.terminal import ( REQUIRED_ABSTRACT_TERMINAL_COLUMNS, REQUIRED_TERMINAL_POINTER_COLUMNS, + default_terminal_cwd, + lease_provider_name, ) from storage.providers.sqlite.connection import create_connection from storage.providers.sqlite.kernel import SQLiteDBRole, resolve_role_db_path @@ -235,11 +237,13 @@ def create( terminal_id: str, thread_id: str, lease_id: str, - initial_cwd: str = "/root", + initial_cwd: str | None = None, ) -> dict[str, Any]: now = datetime.now().isoformat() env_delta_json = "{}" state_version = 0 + provider_name = lease_provider_name(lease_id, self._db_path) + effective_cwd = initial_cwd or default_terminal_cwd(provider_name=provider_name) with self._lock: self._conn.execute( @@ -247,7 +251,7 @@ def create( INSERT INTO abstract_terminals (terminal_id, thread_id, lease_id, cwd, env_delta_json, state_version, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, - (terminal_id, thread_id, lease_id, initial_cwd, env_delta_json, state_version, now, now), + (terminal_id, thread_id, lease_id, effective_cwd, env_delta_json, state_version, now, now), ) self._conn.commit() @@ -257,7 +261,7 @@ def create( "terminal_id": terminal_id, "thread_id": thread_id, "lease_id": lease_id, - "cwd": initial_cwd, + "cwd": effective_cwd, "env_delta_json": env_delta_json, "state_version": state_version, "created_at": now, diff --git a/storage/providers/supabase/terminal_repo.py b/storage/providers/supabase/terminal_repo.py index 631c0a649..782ec7304 100644 --- a/storage/providers/supabase/terminal_repo.py +++ b/storage/providers/supabase/terminal_repo.py @@ -5,6 +5,7 @@ from datetime import datetime from typing import Any +from sandbox.terminal import default_terminal_cwd from storage.providers.supabase import _query as q _REPO = "terminal repo" @@ -30,6 +31,16 @@ def _terminals(self) -> Any: def _pointers(self) -> Any: return self._client.table(_POINTERS_TABLE) + def _provider_name_for_lease(self, lease_id: str) -> str | None: + rows = q.rows( + self._client.table("sandbox_leases").select("provider_name").eq("lease_id", lease_id).limit(1).execute(), + _REPO, + "lookup lease provider", + ) + if not rows or not rows[0].get("provider_name"): + return None + return str(rows[0]["provider_name"]) + # ------------------------------------------------------------------ # Reads # ------------------------------------------------------------------ @@ -145,17 +156,19 @@ def create( terminal_id: str, thread_id: str, lease_id: str, - initial_cwd: str = "/root", + initial_cwd: str | None = None, ) -> dict[str, Any]: now = datetime.now().isoformat() env_delta_json = "{}" state_version = 0 + provider_name = self._provider_name_for_lease(lease_id) + effective_cwd = initial_cwd or default_terminal_cwd(provider_name=provider_name) self._terminals().insert( { "terminal_id": terminal_id, "thread_id": thread_id, "lease_id": lease_id, - "cwd": initial_cwd, + "cwd": effective_cwd, "env_delta_json": env_delta_json, "state_version": state_version, "created_at": now, @@ -167,7 +180,7 @@ def create( "terminal_id": terminal_id, "thread_id": thread_id, "lease_id": lease_id, - "cwd": initial_cwd, + "cwd": effective_cwd, "env_delta_json": env_delta_json, "state_version": state_version, "created_at": now,