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:
+2
-2
@@ -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
|
||||||
|
|||||||
@@ -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,8 +167,11 @@ 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", [])
|
||||||
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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
if (currentWs?.readyState === WebSocket.OPEN) {
|
||||||
|
currentWs.send(JSON.stringify({ type: "resize", cols, rows }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const currentWs = wsRef.current;
|
|
||||||
if (currentWs?.readyState === WebSocket.OPEN && cols > 0 && rows > 0) {
|
|
||||||
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.
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user