fix: container mount permissions, terminal shift, ESC capture

Bug 1 — in-container repo mounting:
- docker-compose.yml: added /data/working-copies:/data/working-copies mount
  to API container so workspace dirs are visible on host filesystem
- Dockerfile: create /data/working-copies dir in image

Bug 2 — /home/user not writable:
- workspace_manager.py: chmod 777 workspace dirs + 666 files after clone
  and after sync, so any container user can write
- manifest_compiler.py: explicit mkdir + chown + chmod 755 for home dir
  in generated Dockerfile

Bug 3 — terminal text shifts left on typing:
- terminal.tsx: removed manual term.refresh() after fit (caused reflow)
- Track lastSentCols/lastSentRows and only send resize when dimensions
  actually changed, preventing resize feedback loops

Bug 4 — ESC key captured by terminal:
- terminal.tsx: attachCustomKeyEventHandler allows ESC to propagate to
  browser when not in alternate buffer (vim/tmux), so modals/navigation
  work; ESC still sent to PTY when in vim/tmux alternate screen

Quality gates: ruff clean, tsc --noEmit clean, pytest workspaces (9 passed)
This commit is contained in:
2026-06-01 22:36:31 +02:00
parent 280a6ff2fa
commit a0cfbbc2d2
9 changed files with 83 additions and 30 deletions
+2 -2
View File
@@ -50,8 +50,8 @@ ENV PATH=/root/.local/bin:$PATH
# Copy application code # Copy application code
COPY --chown=appuser:appgroup . . COPY --chown=appuser:appgroup . .
# Create directories for repo and instance storage # Create directories for repo, instance, and workspace storage
RUN mkdir -p /data/repos /data/instances && chown -R appuser:appgroup /data RUN mkdir -p /data/repos /data/instances /data/working-copies && chown -R appuser:appgroup /data
# Copy wait-for-db script # Copy wait-for-db script
COPY wait-for-db.sh /usr/local/bin/wait-for-db.sh COPY wait-for-db.sh /usr/local/bin/wait-for-db.sh
+4 -1
View File
@@ -26,7 +26,7 @@ def resolve_base(manifest: dict) -> dict:
result = deepcopy(manifest) result = deepcopy(manifest)
base_definition_id = result.pop("base_definition_id", None) base_definition_id = result.pop("base_definition_id", None)
base_version = result.pop("base_version", "latest") result.pop("base_version", None)
if base_definition_id: if base_definition_id:
# This will be provided by the caller (they have the DB session) # This will be provided by the caller (they have the DB session)
@@ -167,6 +167,9 @@ def compile_dockerfile(manifest: dict) -> str:
lines.append(f"ENV HOME={home}") lines.append(f"ENV HOME={home}")
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
lines.append(f"RUN mkdir -p {home} && chown {name}:{name} {home} && chmod 755 {home}")
lines.append("")
# Build scripts # Build scripts
build_scripts = manifest.get("scripts", {}).get("build", []) build_scripts = manifest.get("scripts", {}).get("build", [])
+42 -1
View File
@@ -73,7 +73,13 @@ class WorkspaceManager:
RuntimeError: If git clone fails. RuntimeError: If git clone fails.
""" """
path = self._workspace_path(repo.id, name) path = self._workspace_path(repo.id, name)
os.makedirs(os.path.dirname(path), exist_ok=True) parent = os.path.dirname(path)
os.makedirs(parent, exist_ok=True)
# Ensure container users (various UIDs) can write to workspace dirs
try:
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
@@ -99,6 +105,23 @@ class WorkspaceManager:
await GitService.clone(repo.remote_url, branch, path, ssh_key=ssh_key) 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)
workspace = Workspace( workspace = Workspace(
name=name, name=name,
repo_id=repo.id, repo_id=repo.id,
@@ -190,6 +213,24 @@ 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)
# 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)
+3 -8
View File
@@ -71,16 +71,12 @@ export function StartToolFAB() {
</p> </p>
) : !selectedWorkspace ? ( ) : !selectedWorkspace ? (
<div className="form-group"> <div className="form-group">
<label htmlFor="fab-workspace-select"> <label htmlFor="fab-workspace-select">Select a workspace</label>
Select a workspace
</label>
<select <select
id="fab-workspace-select" id="fab-workspace-select"
value="" value=""
onChange={(e) => { onChange={(e) => {
const ws = workspaces.find( const ws = workspaces.find((w) => w.id === e.target.value);
(w) => w.id === e.target.value,
);
if (ws) setSelectedWorkspace(ws); if (ws) setSelectedWorkspace(ws);
}} }}
> >
@@ -97,8 +93,7 @@ export function StartToolFAB() {
<div className="tool-starter-header"> <div className="tool-starter-header">
<h4> <h4>
{selectedWorkspace.project_name} /{" "} {selectedWorkspace.project_name} /{" "}
{selectedWorkspace.repo_name} /{" "} {selectedWorkspace.repo_name} / {selectedWorkspace.name}
{selectedWorkspace.name}
</h4> </h4>
<button <button
className="ghost-button small" className="ghost-button small"
+23 -9
View File
@@ -285,6 +285,8 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
const container = terminalRef.current; const container = terminalRef.current;
// Define fitTerminal before connectWebSocket so it's available in onmessage // Define fitTerminal before connectWebSocket so it's available in onmessage
let lastSentCols = 0;
let lastSentRows = 0;
const fitTerminal = () => { const fitTerminal = () => {
if (!fitAddonRef.current || !termRef.current) return; if (!fitAddonRef.current || !termRef.current) return;
try { try {
@@ -294,23 +296,35 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
return; return;
} }
const { cols, rows } = termRef.current; const { cols, rows } = termRef.current;
// Force refresh if dimensions are valid // Only send resize when dimensions actually changed
if (cols > 0 && rows > 0) { if (
try { cols > 0 &&
termRef.current.refresh(0, rows - 1); rows > 0 &&
} catch { (cols !== lastSentCols || rows !== lastSentRows)
// Ignore refresh errors ) {
} lastSentCols = cols;
} lastSentRows = rows;
const currentWs = wsRef.current; const currentWs = wsRef.current;
if (currentWs?.readyState === WebSocket.OPEN && cols > 0 && rows > 0) { if (currentWs?.readyState === WebSocket.OPEN) {
currentWs.send(JSON.stringify({ type: "resize", cols, rows })); currentWs.send(JSON.stringify({ type: "resize", cols, rows }));
} }
}
}; };
// 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.
+1 -4
View File
@@ -8,10 +8,7 @@ import {
type Session as SessionApi, type Session as SessionApi,
type InstanceHealth, type InstanceHealth,
} from "../api/sessions"; } from "../api/sessions";
import { import { ErrorState, LoadingState } from "../components/data-states";
ErrorState,
LoadingState,
} from "../components/data-states";
import { SessionList } from "../components/session-list"; import { SessionList } from "../components/session-list";
import { useInstanceActions } from "../hooks/use-instance-actions"; import { useInstanceActions } from "../hooks/use-instance-actions";
+2 -2
View File
@@ -163,8 +163,8 @@ export const SessionsPage = () => {
<h3>Uncommitted Changes</h3> <h3>Uncommitted Changes</h3>
<p> <p>
The repository{" "} The repository{" "}
<strong>{dirtyDeleteSession.repository_name}</strong>{" "} <strong>{dirtyDeleteSession.repository_name}</strong> has
has uncommitted changes. Deleting this session will permanently uncommitted changes. Deleting this session will permanently
lose these changes. lose these changes.
</p> </p>
<div className="changed-files-list"> <div className="changed-files-list">
+3 -1
View File
@@ -5633,7 +5633,9 @@ a:active,
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: transform 0.15s ease, box-shadow 0.15s ease; transition:
transform 0.15s ease,
box-shadow 0.15s ease;
} }
.start-tool-fab:hover { .start-tool-fab:hover {
+1
View File
@@ -63,6 +63,7 @@ services:
volumes: volumes:
- /data/repos:/data/repos - /data/repos:/data/repos
- /data/instances:/data/instances - /data/instances:/data/instances
- /data/working-copies:/data/working-copies
ports: ports:
- "8000:8000" - "8000:8000"
depends_on: depends_on: