From 8837031fd24d3128bab9acee17f4a738ba9d72f8 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Mon, 1 Jun 2026 23:16:06 +0200 Subject: [PATCH] fix: ESC key, .config, workspace permissions, terminal race condition --- apps/api/src/services/manifest_compiler.py | 22 ++++++- apps/api/src/services/terminal_manager.py | 13 +++-- apps/api/src/services/workspace_manager.py | 67 ++++++++++------------ apps/web/src/components/terminal.tsx | 19 +----- 4 files changed, 61 insertions(+), 60 deletions(-) diff --git a/apps/api/src/services/manifest_compiler.py b/apps/api/src/services/manifest_compiler.py index a971268..04f924e 100644 --- a/apps/api/src/services/manifest_compiler.py +++ b/apps/api/src/services/manifest_compiler.py @@ -118,6 +118,11 @@ def compile_dockerfile(manifest: dict) -> str: # System packages (apt) apt_packages = manifest.get("packages", {}).get("apt", []) + if manifest.get("user"): + # Ensure sudo is available for permission-fixing startup scripts + apt_packages = list(apt_packages) + if "sudo" not in apt_packages: + apt_packages.append("sudo") if apt_packages: lines.append("RUN apt-get update && apt-get install -y \\") for pkg in apt_packages[:-1]: @@ -168,10 +173,18 @@ def compile_dockerfile(manifest: dict) -> str: lines.append(f"ENV USER={name}") lines.append("") # Ensure home directory exists and is writable by the user - lines.append(f"RUN mkdir -p {home} && chown {name}:{name} {home} && chmod 755 {home}") + lines.append( + f"RUN mkdir -p {home} && chown {name}:{name} {home} && chmod 755 {home}" + ) lines.append("") - # Build scripts + # Configure passwordless sudo so startup scripts can fix permissions + lines.append( + f'RUN echo "{name} ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/{name} && chmod 0440 /etc/sudoers.d/{name}' + ) + lines.append("") + + # Build scripts build_scripts = manifest.get("scripts", {}).get("build", []) for script in build_scripts: # Normalize multi-line scripts into single RUN command @@ -184,6 +197,11 @@ def compile_dockerfile(manifest: dict) -> str: if build_scripts: lines.append("") + # After build scripts, ensure everything in home is owned by the user + if user and build_scripts: + lines.append(f"RUN chown -R {name}:{name} {home}") + lines.append("") + # Create mount target directories mounts = manifest.get("mounts", []) if mounts: diff --git a/apps/api/src/services/terminal_manager.py b/apps/api/src/services/terminal_manager.py index dfe7ff5..c767161 100644 --- a/apps/api/src/services/terminal_manager.py +++ b/apps/api/src/services/terminal_manager.py @@ -6,6 +6,7 @@ import uuid from datetime import datetime, timezone from fastapi import WebSocket +from sqlalchemy.dialects.postgresql import insert as pg_insert from src.database import SessionLocal from src.models.terminal_session import TerminalSessionModel @@ -83,18 +84,22 @@ class TerminalManager: instance_id: uuid.UUID, name: str, ) -> None: - """Insert a TerminalSessionModel row into the database.""" + """Insert a TerminalSessionModel row into the database. + + Uses ON CONFLICT DO NOTHING to handle races when a session is + restored from DB and then re-inserted. + """ try: async with SessionLocal() as db_session: - db_row = TerminalSessionModel( + stmt = pg_insert(TerminalSessionModel).values( id=uuid.UUID(session_id), instance_id=instance_id, name=name, status="active", created_at=datetime.now(timezone.utc), last_activity_at=datetime.now(timezone.utc), - ) - db_session.add(db_row) + ).on_conflict_do_nothing(index_elements=["id"]) + await db_session.execute(stmt) await db_session.commit() logger.debug( "Inserted terminal session row %s for instance %s", diff --git a/apps/api/src/services/workspace_manager.py b/apps/api/src/services/workspace_manager.py index ba1fb37..f6ee94b 100644 --- a/apps/api/src/services/workspace_manager.py +++ b/apps/api/src/services/workspace_manager.py @@ -2,9 +2,11 @@ from __future__ import annotations +import contextlib import logging import os import shutil +import stat import uuid from dataclasses import dataclass from datetime import datetime @@ -76,10 +78,8 @@ class WorkspaceManager: parent = os.path.dirname(path) os.makedirs(parent, exist_ok=True) # Ensure container users (various UIDs) can write to workspace dirs - try: + with contextlib.suppress(OSError): os.chmod(parent, 0o777) - except OSError: - pass logger.info( "Creating workspace: name=%s, repo=%s, branch=%s", name, repo.id, branch @@ -104,23 +104,7 @@ class WorkspaceManager: ).decode() await GitService.clone(repo.remote_url, branch, path, ssh_key=ssh_key) - - # Make workspace writable for any container user - try: - os.chmod(path, 0o777) - for root, dirs, files in os.walk(path): - for d in dirs: - try: - os.chmod(os.path.join(root, d), 0o777) - except OSError: - pass - for f in files: - try: - os.chmod(os.path.join(root, f), 0o666) - except OSError: - pass - except OSError: - logger.warning("Failed to chmod workspace path: %s", path) + self._make_world_writable(path) workspace = Workspace( name=name, @@ -213,28 +197,37 @@ class WorkspaceManager: return SyncResult(branch_deleted=True) await GitService.pull(workspace.path, workspace.branch, ssh_key=ssh_key) - - # Re-apply permissive permissions after sync - try: - os.chmod(workspace.path, 0o777) - for root, dirs, files in os.walk(workspace.path): - for d in dirs: - try: - os.chmod(os.path.join(root, d), 0o777) - except OSError: - pass - for f in files: - try: - os.chmod(os.path.join(root, f), 0o666) - except OSError: - pass - except OSError: - logger.warning("Failed to chmod workspace after sync: %s", workspace.path) + self._make_world_writable(workspace.path) workspace.last_sync_at = datetime.now() logger.info("Workspace synced: %s", workspace.id) return SyncResult(branch_deleted=False) + def _make_world_writable(self, path: str) -> None: + """Recursively make path readable/writable/traversable by any UID. + + Directories get 777 (traversable). Files get rw for all while + preserving any existing execute bits. + """ + with contextlib.suppress(OSError): + os.chmod(path, 0o777) + for root, dirs, files in os.walk(path): + for d in dirs: + dpath = os.path.join(root, d) + with contextlib.suppress(OSError): + os.chmod(dpath, 0o777) + for f in files: + fpath = os.path.join(root, f) + with contextlib.suppress(OSError): + mode = os.stat(fpath).st_mode + # Preserve execute bits, ensure read+write for all + new_mode = (mode & stat.S_IXUSR) | 0o666 + if mode & stat.S_IXGRP: + new_mode |= stat.S_IXGRP + if mode & stat.S_IXOTH: + new_mode |= stat.S_IXOTH + os.chmod(fpath, new_mode) + async def _get_instances( self, workspace: Workspace, diff --git a/apps/web/src/components/terminal.tsx b/apps/web/src/components/terminal.tsx index 06a3d47..5b963fe 100644 --- a/apps/web/src/components/terminal.tsx +++ b/apps/web/src/components/terminal.tsx @@ -314,17 +314,6 @@ export const TerminalComponent = React.forwardRef( // Open xterm first (must happen before fit) term.open(container); term.focus(); - - // Allow ESC to propagate to browser when not in alternate buffer (vim/tmux) - term.attachCustomKeyEventHandler((e) => { - if (e.key === "Escape") { - const isAlternate = - term.buffer.active.type === "alternate"; - return isAlternate; // true = xterm handles it, false = browser handles it - } - return true; - }); - const ws = connectWebSocket(); // Mobile touch scroll. @@ -366,16 +355,12 @@ export const TerminalComponent = React.forwardRef( // If the viewport is scrollable, scroll it directly. // Otherwise we are in alternate screen (tmux/vim) and must // send SGR 1006 mouse-wheel protocol data. - const hasScrollback = - viewport.scrollHeight > viewport.clientHeight; + const hasScrollback = viewport.scrollHeight > viewport.clientHeight; if (hasScrollback) { viewport.scrollTop += deltaY; } else { const ws = wsRef.current; - if ( - ws?.readyState === WebSocket.OPEN && - termRef.current - ) { + if (ws?.readyState === WebSocket.OPEN && termRef.current) { // Use the cursor position as the wheel location so // tmux knows which pane to scroll. const buf = termRef.current.buffer.active;