feat: terminal startup command and container tools

- Add startup_command field to ToolType model and API
- Execute startup command before interactive shell in terminal sessions
- Add tmux and ranger to OpenCode container spec
- Update Tool Workshop UI with startup_command input for terminal types
- Add backend tests for startup_command CRUD operations
- Sync specs: tool-terminal, tool-types-definition, opencode-web-server
- New spec: tool-terminal-startup-command

Quality gates: Frontend typecheck/lint passed. Backend tests blocked by environment (Python/Docker not available).

OpenSpec: terminal-startup-and-container-tools
This commit is contained in:
OpenCode Agent
2026-05-24 22:21:55 +00:00
parent a4c429d53a
commit 01a0ef46c9
22 changed files with 1060 additions and 78 deletions
+14
View File
@@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_db_session
from src.models.tool_instance import ToolInstance
from src.models.tool_type import ToolType
from src.services.terminal_manager import terminal_manager
router = APIRouter()
@@ -81,11 +82,18 @@ async def terminal_websocket(
logger.info("Terminal auth passed for instance %s, user %s", instance_id, user_id)
# Fetch tool type to get startup_command
tool_type = await db_session.get(ToolType, instance.tool_type_id)
startup_command = tool_type.startup_command if tool_type else None
if startup_command:
logger.info("Using startup command for instance %s: %s", instance_id, startup_command)
# Get or create terminal session
try:
session = await terminal_manager.get_or_create_session(
instance_uuid,
instance.container_id,
startup_command=startup_command,
)
logger.info("Terminal session ready for instance %s (session_id=%s)", instance_id, session.session_id)
@@ -186,6 +194,7 @@ async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> N
new_session = await terminal_manager.reset_session(
session.instance_id,
session.container_id,
startup_command=session.startup_command,
)
# Update the mutable session reference so read_loop uses the new session
@@ -259,11 +268,16 @@ async def reset_terminal_session(
detail="Instance is not running"
)
# Fetch tool type to get startup_command
tool_type = await db_session.get(ToolType, instance.tool_type_id)
startup_command = tool_type.startup_command if tool_type else None
try:
# Reset the session
new_session = await terminal_manager.reset_session(
instance_id,
instance.container_id,
startup_command=startup_command,
)
logger.info("Terminal session reset for instance %s (new session_id=%s)", instance_id, new_session.session_id)
+4
View File
@@ -49,6 +49,7 @@ class ToolTypeCreate(BaseModel):
dockerfile_template: str | None = None
build_context: dict | None = None
readiness_probe: dict | None = None
startup_command: str | None = None
required_variables: list[str] = []
category: str = "other"
interface_type: str = "web"
@@ -192,6 +193,7 @@ class ToolTypeUpdate(BaseModel):
dockerfile_template: str | None = None
build_context: dict | None = None
readiness_probe: dict | None = None
startup_command: str | None = None
required_variables: list[str] | None = None
category: str | None = None
interface_type: str | None = None
@@ -278,6 +280,7 @@ class ToolTypeResponse(BaseModel):
dockerfile_template: str | None
build_context: dict | None
readiness_probe: dict | None
startup_command: str | None
required_variables: list[str]
created_by_id: uuid.UUID | None
created_at: datetime
@@ -324,6 +327,7 @@ async def create_tool_type(
dockerfile_template=data.dockerfile_template,
build_context=data.build_context,
readiness_probe=data.readiness_probe,
startup_command=data.startup_command,
required_variables=data.required_variables,
category=data.category,
interface_type=data.interface_type,
+1
View File
@@ -30,6 +30,7 @@ class ToolType(UUIDPrimaryKeyMixin, TimestampMixin, Base):
JSON, default=dict, nullable=True
)
readiness_probe: Mapped[dict | None] = mapped_column(JSON, nullable=True)
startup_command: Mapped[str | None] = mapped_column(Text, nullable=True)
required_variables: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
created_by_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(),
+6 -4
View File
@@ -58,6 +58,7 @@ class TerminalManager:
self,
instance_id: uuid.UUID,
container_id: str,
startup_command: str | None = None,
) -> TerminalSession:
"""Get existing session or create a new one."""
# Ensure idle check is running (lazy start)
@@ -82,8 +83,8 @@ class TerminalManager:
# Create new session
logger.info("Creating new terminal session for instance %s", instance_id)
session_id = str(uuid.uuid4())
session = TerminalSession(session_id, instance_id, container_id)
await session.start()
session = TerminalSession(session_id, instance_id, container_id, startup_command=startup_command)
await session.start(startup_command=startup_command)
self._sessions[instance_id_str] = session
return session
@@ -127,6 +128,7 @@ class TerminalManager:
self,
instance_id: uuid.UUID,
container_id: str,
startup_command: str | None = None,
) -> TerminalSession:
"""Reset a session by killing it and creating a new one."""
instance_id_str = str(instance_id)
@@ -139,8 +141,8 @@ class TerminalManager:
# Create new session
session_id = str(uuid.uuid4())
session = TerminalSession(session_id, instance_id, container_id)
await session.start()
session = TerminalSession(session_id, instance_id, container_id, startup_command=startup_command)
await session.start(startup_command=startup_command)
self._sessions[instance_id_str] = session
return session
+12 -3
View File
@@ -29,10 +29,11 @@ class TerminalSession:
# Idle timeout in seconds (30 minutes)
IDLE_TIMEOUT = 30 * 60
def __init__(self, session_id: str, instance_id: uuid.UUID, container_id: str) -> None:
def __init__(self, session_id: str, instance_id: uuid.UUID, container_id: str, startup_command: str | None = None) -> None:
self.session_id = session_id
self.instance_id = instance_id
self.container_id = container_id
self.startup_command = startup_command
self.process: asyncio.subprocess.Process | None = None
self._closed = False
self._master_fd: int | None = None
@@ -52,7 +53,7 @@ class TerminalSession:
self._cols = 80
self._rows = 24
async def start(self) -> None:
async def start(self, startup_command: str | None = None) -> None:
"""Start the docker exec process with a shell using a PTY."""
# Create a pseudo-terminal on the host
self._master_fd, self._slave_fd = pty.openpty()
@@ -61,6 +62,13 @@ class TerminalSession:
self._set_terminal_size(self._cols, self._rows)
logger.info(f"Starting terminal session {self.session_id} for container {self.container_id} with initial size {self._cols}x{self._rows}")
# Build the shell command
if startup_command:
shell_cmd = f'bash -c "{startup_command}" || true; exec bash -il'
logger.info(f"Using startup command for session {self.session_id}: {startup_command}")
else:
shell_cmd = "bash -il"
# Start docker exec with the slave fd as stdin/stdout/stderr
# Using -it because the slave fd IS a TTY
self.process = await asyncio.create_subprocess_exec(
@@ -71,7 +79,8 @@ class TerminalSession:
"TERM=xterm",
self.container_id,
"bash",
"-il",
"-c",
shell_cmd,
stdin=self._slave_fd,
stdout=self._slave_fd,
stderr=self._slave_fd,