fix: ESC key, .config, workspace permissions, terminal race condition

This commit is contained in:
2026-06-01 23:16:06 +02:00
parent a0cfbbc2d2
commit 8837031fd2
4 changed files with 61 additions and 60 deletions
+20 -2
View File
@@ -118,6 +118,11 @@ def compile_dockerfile(manifest: dict) -> str:
# System packages (apt) # System packages (apt)
apt_packages = manifest.get("packages", {}).get("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: if apt_packages:
lines.append("RUN apt-get update && apt-get install -y \\") lines.append("RUN apt-get update && apt-get install -y \\")
for pkg in apt_packages[:-1]: for pkg in apt_packages[:-1]:
@@ -168,10 +173,18 @@ def compile_dockerfile(manifest: dict) -> str:
lines.append(f"ENV USER={name}") lines.append(f"ENV USER={name}")
lines.append("") lines.append("")
# Ensure home directory exists and is writable by the user # 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("") 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", []) build_scripts = manifest.get("scripts", {}).get("build", [])
for script in build_scripts: for script in build_scripts:
# Normalize multi-line scripts into single RUN command # Normalize multi-line scripts into single RUN command
@@ -184,6 +197,11 @@ def compile_dockerfile(manifest: dict) -> str:
if build_scripts: if build_scripts:
lines.append("") 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 # Create mount target directories
mounts = manifest.get("mounts", []) mounts = manifest.get("mounts", [])
if mounts: if mounts:
+9 -4
View File
@@ -6,6 +6,7 @@ import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import WebSocket from fastapi import WebSocket
from sqlalchemy.dialects.postgresql import insert as pg_insert
from src.database import SessionLocal from src.database import SessionLocal
from src.models.terminal_session import TerminalSessionModel from src.models.terminal_session import TerminalSessionModel
@@ -83,18 +84,22 @@ class TerminalManager:
instance_id: uuid.UUID, instance_id: uuid.UUID,
name: str, name: str,
) -> None: ) -> 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: try:
async with SessionLocal() as db_session: async with SessionLocal() as db_session:
db_row = TerminalSessionModel( stmt = pg_insert(TerminalSessionModel).values(
id=uuid.UUID(session_id), id=uuid.UUID(session_id),
instance_id=instance_id, instance_id=instance_id,
name=name, name=name,
status="active", status="active",
created_at=datetime.now(timezone.utc), created_at=datetime.now(timezone.utc),
last_activity_at=datetime.now(timezone.utc), last_activity_at=datetime.now(timezone.utc),
) ).on_conflict_do_nothing(index_elements=["id"])
db_session.add(db_row) await db_session.execute(stmt)
await db_session.commit() await db_session.commit()
logger.debug( logger.debug(
"Inserted terminal session row %s for instance %s", "Inserted terminal session row %s for instance %s",
+30 -37
View File
@@ -2,9 +2,11 @@
from __future__ import annotations from __future__ import annotations
import contextlib
import logging import logging
import os import os
import shutil import shutil
import stat
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
@@ -76,10 +78,8 @@ class WorkspaceManager:
parent = os.path.dirname(path) parent = os.path.dirname(path)
os.makedirs(parent, exist_ok=True) os.makedirs(parent, exist_ok=True)
# Ensure container users (various UIDs) can write to workspace dirs # Ensure container users (various UIDs) can write to workspace dirs
try: with contextlib.suppress(OSError):
os.chmod(parent, 0o777) os.chmod(parent, 0o777)
except OSError:
pass
logger.info( logger.info(
"Creating workspace: name=%s, repo=%s, branch=%s", name, repo.id, branch "Creating workspace: name=%s, repo=%s, branch=%s", name, repo.id, branch
@@ -104,23 +104,7 @@ class WorkspaceManager:
).decode() ).decode()
await GitService.clone(repo.remote_url, branch, path, ssh_key=ssh_key) await GitService.clone(repo.remote_url, branch, path, ssh_key=ssh_key)
self._make_world_writable(path)
# 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)
workspace = Workspace( workspace = Workspace(
name=name, name=name,
@@ -213,28 +197,37 @@ class WorkspaceManager:
return SyncResult(branch_deleted=True) return SyncResult(branch_deleted=True)
await GitService.pull(workspace.path, workspace.branch, ssh_key=ssh_key) await GitService.pull(workspace.path, workspace.branch, ssh_key=ssh_key)
self._make_world_writable(workspace.path)
# 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)
workspace.last_sync_at = datetime.now() workspace.last_sync_at = datetime.now()
logger.info("Workspace synced: %s", workspace.id) logger.info("Workspace synced: %s", workspace.id)
return SyncResult(branch_deleted=False) 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( async def _get_instances(
self, self,
workspace: Workspace, workspace: Workspace,
+2 -17
View File
@@ -314,17 +314,6 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
// Open xterm first (must happen before fit) // Open xterm first (must happen before fit)
term.open(container); term.open(container);
term.focus(); 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(); const ws = connectWebSocket();
// Mobile touch scroll. // Mobile touch scroll.
@@ -366,16 +355,12 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
// If the viewport is scrollable, scroll it directly. // If the viewport is scrollable, scroll it directly.
// Otherwise we are in alternate screen (tmux/vim) and must // Otherwise we are in alternate screen (tmux/vim) and must
// send SGR 1006 mouse-wheel protocol data. // send SGR 1006 mouse-wheel protocol data.
const hasScrollback = const hasScrollback = viewport.scrollHeight > viewport.clientHeight;
viewport.scrollHeight > viewport.clientHeight;
if (hasScrollback) { if (hasScrollback) {
viewport.scrollTop += deltaY; viewport.scrollTop += deltaY;
} else { } else {
const ws = wsRef.current; const ws = wsRef.current;
if ( if (ws?.readyState === WebSocket.OPEN && termRef.current) {
ws?.readyState === WebSocket.OPEN &&
termRef.current
) {
// Use the cursor position as the wheel location so // Use the cursor position as the wheel location so
// tmux knows which pane to scroll. // tmux knows which pane to scroll.
const buf = termRef.current.buffer.active; const buf = termRef.current.buffer.active;