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
@@ -0,0 +1,30 @@
"""add startup_command to tool_types
Revision ID: 2026_05_24_220141
Revises: 6fc7bfcf199f
Create Date: 2026-05-24 22:01:41.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "2026_05_24_220141"
down_revision: Union[str, Sequence[str], None] = "6fc7bfcf199f"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"tool_types",
sa.Column("startup_command", sa.Text(), nullable=True),
)
def downgrade() -> None:
op.drop_column("tool_types", "startup_command")
+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,
@@ -222,4 +222,78 @@ class TestToolTypesAPIExtended:
)
assert response.status_code == 422
data = response.json()
def test_create_tool_type_with_startup_command(self, authenticated_client: TestClient) -> None:
"""Test creating a tool type with startup_command."""
response = authenticated_client.post(
"/tool-types",
json={
"name": "startup-tool",
"display_name": "Startup Tool",
"category": "utility",
"interface_type": "terminal",
"requires_port": False,
"default_port": 0,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: alpine",
"startup_command": "cd /workspace && ls",
"required_variables": [],
},
)
assert response.status_code == 201
data = response.json()
assert data["startup_command"] == "cd /workspace && ls"
assert data["interface_type"] == "terminal"
def test_update_tool_type_startup_command(self, authenticated_client: TestClient) -> None:
"""Test updating a tool type's startup_command."""
# Create tool type first
create_response = authenticated_client.post(
"/tool-types",
json={
"name": "update-startup-tool",
"display_name": "Update Startup Tool",
"interface_type": "terminal",
"requires_port": False,
"default_port": 0,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: alpine",
"required_variables": [],
},
)
tool_id = create_response.json()["id"]
# Update with startup_command
response = authenticated_client.put(
f"/tool-types/{tool_id}",
json={
"startup_command": "source /etc/profile",
},
)
assert response.status_code == 200
data = response.json()
assert data["startup_command"] == "source /etc/profile"
def test_get_tool_type_returns_startup_command(self, authenticated_client: TestClient) -> None:
"""Test that GET returns startup_command."""
create_response = authenticated_client.post(
"/tool-types",
json={
"name": "get-startup-tool",
"display_name": "Get Startup Tool",
"interface_type": "terminal",
"requires_port": False,
"default_port": 0,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: alpine",
"startup_command": "echo hello",
"required_variables": [],
},
)
tool_id = create_response.json()["id"]
response = authenticated_client.get(f"/tool-types/{tool_id}")
assert response.status_code == 200
data = response.json()
assert data["startup_command"] == "echo hello"
assert "Port 9999 is not exposed" in str(data)