docketeer-subprocess 0.0.16__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- docketeer_subprocess-0.0.16/.gitignore +12 -0
- docketeer_subprocess-0.0.16/CLAUDE.md +17 -0
- docketeer_subprocess-0.0.16/PKG-INFO +62 -0
- docketeer_subprocess-0.0.16/README.md +45 -0
- docketeer_subprocess-0.0.16/pyproject.toml +57 -0
- docketeer_subprocess-0.0.16/src/docketeer_subprocess/__init__.py +8 -0
- docketeer_subprocess-0.0.16/src/docketeer_subprocess/executor.py +88 -0
- docketeer_subprocess-0.0.16/src/docketeer_subprocess/mcp_bridge.py +74 -0
- docketeer_subprocess-0.0.16/tests/__init__.py +0 -0
- docketeer_subprocess-0.0.16/tests/test_executor.py +115 -0
- docketeer_subprocess-0.0.16/tests/test_mcp_bridge.py +184 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# docketeer-subprocess
|
|
2
|
+
|
|
3
|
+
Unsandboxed subprocess command execution. Implements the `docketeer.executor`
|
|
4
|
+
entry point for environments where bubblewrap is unavailable or redundant.
|
|
5
|
+
|
|
6
|
+
## Structure
|
|
7
|
+
|
|
8
|
+
- **`executor.py`** — the `SubprocessExecutor`. Runs commands as plain
|
|
9
|
+
subprocesses. Uses the first mount's `source` as `cwd`. Ignores sandbox
|
|
10
|
+
parameters (network_access, username, mounts for remapping).
|
|
11
|
+
- **`mcp_bridge.py`** — copied from bubblewrap, bridges Claude's stdio-based
|
|
12
|
+
MCP transport to the docketeer unix socket. Stdlib-only.
|
|
13
|
+
|
|
14
|
+
## Testing
|
|
15
|
+
|
|
16
|
+
Tests mock `asyncio.create_subprocess_exec`. No real subprocesses are spawned
|
|
17
|
+
in tests. Verify cwd selection, env merging, and claude arg construction.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: docketeer-subprocess
|
|
3
|
+
Version: 0.0.16
|
|
4
|
+
Summary: Unsandboxed subprocess executor plugin for Docketeer
|
|
5
|
+
Project-URL: Homepage, https://github.com/chrisguidry/docketeer
|
|
6
|
+
Project-URL: Repository, https://github.com/chrisguidry/docketeer
|
|
7
|
+
Project-URL: Issues, https://github.com/chrisguidry/docketeer/issues
|
|
8
|
+
Author-email: Chris Guidry <guid@omg.lol>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
14
|
+
Requires-Python: >=3.12
|
|
15
|
+
Requires-Dist: docketeer
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# docketeer-subprocess
|
|
19
|
+
|
|
20
|
+
Unsandboxed command execution for [Docketeer](https://github.com/chrisguidry/docketeer)
|
|
21
|
+
using plain subprocesses.
|
|
22
|
+
|
|
23
|
+
This plugin provides a `CommandExecutor` implementation that runs external
|
|
24
|
+
programs directly as subprocesses of the current process, with no sandboxing.
|
|
25
|
+
|
|
26
|
+
## When to use this
|
|
27
|
+
|
|
28
|
+
Use `docketeer-subprocess` instead of `docketeer-bubblewrap` when:
|
|
29
|
+
|
|
30
|
+
- Running inside a **container** (Docker, Podman, etc.) that already provides
|
|
31
|
+
isolation
|
|
32
|
+
- Running on a **non-Linux host** where bubblewrap isn't available (macOS,
|
|
33
|
+
Windows/WSL)
|
|
34
|
+
- Running on a **dedicated machine** where the overhead of namespace isolation
|
|
35
|
+
isn't needed
|
|
36
|
+
- You don't have unprivileged user namespaces enabled
|
|
37
|
+
|
|
38
|
+
## Configuration
|
|
39
|
+
|
|
40
|
+
| Variable | Default | Description |
|
|
41
|
+
|----------|---------|-------------|
|
|
42
|
+
| `DOCKETEER_EXECUTOR` | _(auto)_ | Set to `subprocess` when both executor plugins are installed |
|
|
43
|
+
|
|
44
|
+
If `docketeer-subprocess` is the only executor plugin installed, it's selected
|
|
45
|
+
automatically. If both `docketeer-bubblewrap` and `docketeer-subprocess` are
|
|
46
|
+
installed, set `DOCKETEER_EXECUTOR=subprocess` to choose this one.
|
|
47
|
+
|
|
48
|
+
## How it works
|
|
49
|
+
|
|
50
|
+
The executor runs commands via `asyncio.create_subprocess_exec` with the
|
|
51
|
+
current user's environment. The `CommandExecutor` ABC parameters are handled
|
|
52
|
+
as follows:
|
|
53
|
+
|
|
54
|
+
- **`mounts`** — accepted but not used for filesystem remapping. The first
|
|
55
|
+
mount's `source` is used as the working directory.
|
|
56
|
+
- **`network_access`** — ignored (always available)
|
|
57
|
+
- **`username`** — ignored (runs as current user)
|
|
58
|
+
- **`env`** — merged with `os.environ`, caller values override
|
|
59
|
+
|
|
60
|
+
The `start_claude` method launches `claude -p` with an MCP bridge that
|
|
61
|
+
connects Claude's stdio transport to docketeer's unix socket, the same way
|
|
62
|
+
the bubblewrap executor does.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# docketeer-subprocess
|
|
2
|
+
|
|
3
|
+
Unsandboxed command execution for [Docketeer](https://github.com/chrisguidry/docketeer)
|
|
4
|
+
using plain subprocesses.
|
|
5
|
+
|
|
6
|
+
This plugin provides a `CommandExecutor` implementation that runs external
|
|
7
|
+
programs directly as subprocesses of the current process, with no sandboxing.
|
|
8
|
+
|
|
9
|
+
## When to use this
|
|
10
|
+
|
|
11
|
+
Use `docketeer-subprocess` instead of `docketeer-bubblewrap` when:
|
|
12
|
+
|
|
13
|
+
- Running inside a **container** (Docker, Podman, etc.) that already provides
|
|
14
|
+
isolation
|
|
15
|
+
- Running on a **non-Linux host** where bubblewrap isn't available (macOS,
|
|
16
|
+
Windows/WSL)
|
|
17
|
+
- Running on a **dedicated machine** where the overhead of namespace isolation
|
|
18
|
+
isn't needed
|
|
19
|
+
- You don't have unprivileged user namespaces enabled
|
|
20
|
+
|
|
21
|
+
## Configuration
|
|
22
|
+
|
|
23
|
+
| Variable | Default | Description |
|
|
24
|
+
|----------|---------|-------------|
|
|
25
|
+
| `DOCKETEER_EXECUTOR` | _(auto)_ | Set to `subprocess` when both executor plugins are installed |
|
|
26
|
+
|
|
27
|
+
If `docketeer-subprocess` is the only executor plugin installed, it's selected
|
|
28
|
+
automatically. If both `docketeer-bubblewrap` and `docketeer-subprocess` are
|
|
29
|
+
installed, set `DOCKETEER_EXECUTOR=subprocess` to choose this one.
|
|
30
|
+
|
|
31
|
+
## How it works
|
|
32
|
+
|
|
33
|
+
The executor runs commands via `asyncio.create_subprocess_exec` with the
|
|
34
|
+
current user's environment. The `CommandExecutor` ABC parameters are handled
|
|
35
|
+
as follows:
|
|
36
|
+
|
|
37
|
+
- **`mounts`** — accepted but not used for filesystem remapping. The first
|
|
38
|
+
mount's `source` is used as the working directory.
|
|
39
|
+
- **`network_access`** — ignored (always available)
|
|
40
|
+
- **`username`** — ignored (runs as current user)
|
|
41
|
+
- **`env`** — merged with `os.environ`, caller values override
|
|
42
|
+
|
|
43
|
+
The `start_claude` method launches `claude -p` with an MCP bridge that
|
|
44
|
+
connects Claude's stdio transport to docketeer's unix socket, the same way
|
|
45
|
+
the bubblewrap executor does.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "docketeer-subprocess"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "Unsandboxed subprocess executor plugin for Docketeer"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Chris Guidry", email = "guid@omg.lol" }
|
|
8
|
+
]
|
|
9
|
+
license = "MIT"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
16
|
+
]
|
|
17
|
+
dependencies = [
|
|
18
|
+
"docketeer",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://github.com/chrisguidry/docketeer"
|
|
23
|
+
Repository = "https://github.com/chrisguidry/docketeer"
|
|
24
|
+
Issues = "https://github.com/chrisguidry/docketeer/issues"
|
|
25
|
+
|
|
26
|
+
[project.entry-points."docketeer.executor"]
|
|
27
|
+
subprocess = "docketeer_subprocess"
|
|
28
|
+
|
|
29
|
+
[build-system]
|
|
30
|
+
requires = ["hatchling", "hatch-vcs"]
|
|
31
|
+
build-backend = "hatchling.build"
|
|
32
|
+
|
|
33
|
+
[tool.hatch.version]
|
|
34
|
+
source = "vcs"
|
|
35
|
+
raw-options.root = ".."
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.wheel]
|
|
38
|
+
packages = ["src/docketeer_subprocess"]
|
|
39
|
+
|
|
40
|
+
[tool.uv.sources]
|
|
41
|
+
docketeer = { workspace = true }
|
|
42
|
+
|
|
43
|
+
[tool.pytest.ini_options]
|
|
44
|
+
minversion = "9.0"
|
|
45
|
+
timeout = 1
|
|
46
|
+
addopts = [
|
|
47
|
+
"--import-mode=importlib",
|
|
48
|
+
"--cov=docketeer_subprocess",
|
|
49
|
+
"--cov=tests",
|
|
50
|
+
"--cov-branch",
|
|
51
|
+
"--cov-report=term-missing",
|
|
52
|
+
"--cov-fail-under=100",
|
|
53
|
+
]
|
|
54
|
+
asyncio_mode = "auto"
|
|
55
|
+
filterwarnings = [
|
|
56
|
+
"error",
|
|
57
|
+
]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Unsandboxed subprocess command executor."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from docketeer.executor import (
|
|
10
|
+
ClaudeInvocation,
|
|
11
|
+
CommandExecutor,
|
|
12
|
+
Mount,
|
|
13
|
+
RunningProcess,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
_MCP_BRIDGE_PATH = Path(__file__).resolve().parent / "mcp_bridge.py"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SubprocessExecutor(CommandExecutor):
|
|
20
|
+
"""Runs commands as plain subprocesses with no sandboxing."""
|
|
21
|
+
|
|
22
|
+
async def start(
|
|
23
|
+
self,
|
|
24
|
+
command: list[str],
|
|
25
|
+
*,
|
|
26
|
+
env: dict[str, str] | None = None,
|
|
27
|
+
mounts: list[Mount] | None = None,
|
|
28
|
+
network_access: bool = False,
|
|
29
|
+
username: str | None = None,
|
|
30
|
+
) -> RunningProcess:
|
|
31
|
+
merged_env = dict(os.environ)
|
|
32
|
+
if env:
|
|
33
|
+
merged_env.update(env)
|
|
34
|
+
|
|
35
|
+
cwd: Path | None = None
|
|
36
|
+
if mounts:
|
|
37
|
+
cwd = mounts[0].source
|
|
38
|
+
|
|
39
|
+
process = await asyncio.create_subprocess_exec(
|
|
40
|
+
*command,
|
|
41
|
+
stdin=asyncio.subprocess.PIPE,
|
|
42
|
+
stdout=asyncio.subprocess.PIPE,
|
|
43
|
+
stderr=asyncio.subprocess.PIPE,
|
|
44
|
+
env=merged_env,
|
|
45
|
+
cwd=cwd,
|
|
46
|
+
)
|
|
47
|
+
return RunningProcess(process)
|
|
48
|
+
|
|
49
|
+
async def start_claude( # pragma: no cover — integration path
|
|
50
|
+
self,
|
|
51
|
+
invocation: ClaudeInvocation,
|
|
52
|
+
*,
|
|
53
|
+
env: dict[str, str] | None = None,
|
|
54
|
+
) -> RunningProcess:
|
|
55
|
+
claude_path = shutil.which("claude")
|
|
56
|
+
if not claude_path:
|
|
57
|
+
raise RuntimeError("claude not found on PATH")
|
|
58
|
+
|
|
59
|
+
args: list[str] = [claude_path, "--tools", ""]
|
|
60
|
+
|
|
61
|
+
if invocation.mcp_socket_path:
|
|
62
|
+
mcp_config = json.dumps(
|
|
63
|
+
{
|
|
64
|
+
"mcpServers": {
|
|
65
|
+
"docketeer": {
|
|
66
|
+
"command": "python3",
|
|
67
|
+
"args": [
|
|
68
|
+
str(_MCP_BRIDGE_PATH),
|
|
69
|
+
str(invocation.mcp_socket_path),
|
|
70
|
+
],
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
args.extend(["--mcp-config", mcp_config])
|
|
76
|
+
|
|
77
|
+
args.extend(invocation.claude_args)
|
|
78
|
+
|
|
79
|
+
process = await asyncio.create_subprocess_exec(
|
|
80
|
+
*args,
|
|
81
|
+
stdin=asyncio.subprocess.PIPE,
|
|
82
|
+
stdout=asyncio.subprocess.PIPE,
|
|
83
|
+
stderr=asyncio.subprocess.PIPE,
|
|
84
|
+
env=env,
|
|
85
|
+
cwd=invocation.workspace,
|
|
86
|
+
limit=10 * 1024 * 1024,
|
|
87
|
+
)
|
|
88
|
+
return RunningProcess(process)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""MCP bridge: relays stdin/stdout to a Unix domain socket.
|
|
3
|
+
|
|
4
|
+
Stdlib-only so it can run inside a minimal sandbox without any
|
|
5
|
+
pip-installed packages.
|
|
6
|
+
|
|
7
|
+
Usage: python3 mcp_bridge.py <socket_path>
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import socket
|
|
12
|
+
import sys
|
|
13
|
+
import threading
|
|
14
|
+
from typing import BinaryIO
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def relay(
|
|
18
|
+
sock: socket.socket,
|
|
19
|
+
stdin: BinaryIO,
|
|
20
|
+
stdout: BinaryIO,
|
|
21
|
+
) -> None:
|
|
22
|
+
"""Relay newline-delimited messages between stdin/stdout and a socket.
|
|
23
|
+
|
|
24
|
+
Reads from stdin and forwards to the socket in a background thread.
|
|
25
|
+
Reads from the socket and writes to stdout in the calling thread.
|
|
26
|
+
Returns when the socket closes or stdin reaches EOF.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def stdin_to_socket() -> None:
|
|
30
|
+
try:
|
|
31
|
+
while True:
|
|
32
|
+
line = stdin.readline()
|
|
33
|
+
if not line:
|
|
34
|
+
break
|
|
35
|
+
sock.sendall(line)
|
|
36
|
+
except (BrokenPipeError, OSError):
|
|
37
|
+
pass
|
|
38
|
+
finally:
|
|
39
|
+
with contextlib.suppress(OSError):
|
|
40
|
+
sock.shutdown(socket.SHUT_WR)
|
|
41
|
+
|
|
42
|
+
writer = threading.Thread(target=stdin_to_socket, daemon=True)
|
|
43
|
+
writer.start()
|
|
44
|
+
|
|
45
|
+
buf = b""
|
|
46
|
+
try:
|
|
47
|
+
while True:
|
|
48
|
+
data = sock.recv(4096)
|
|
49
|
+
if not data:
|
|
50
|
+
break
|
|
51
|
+
buf += data
|
|
52
|
+
while b"\n" in buf:
|
|
53
|
+
line, buf = buf.split(b"\n", 1)
|
|
54
|
+
stdout.write(line + b"\n")
|
|
55
|
+
stdout.flush()
|
|
56
|
+
except (BrokenPipeError, OSError):
|
|
57
|
+
pass
|
|
58
|
+
finally:
|
|
59
|
+
sock.close()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def main() -> None:
|
|
63
|
+
if len(sys.argv) != 2:
|
|
64
|
+
print("Usage: mcp_bridge.py <socket_path>", file=sys.stderr)
|
|
65
|
+
sys.exit(1)
|
|
66
|
+
|
|
67
|
+
socket_path = sys.argv[1]
|
|
68
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
69
|
+
sock.connect(socket_path)
|
|
70
|
+
relay(sock, sys.stdin.buffer, sys.stdout.buffer)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
if __name__ == "__main__": # pragma: no cover
|
|
74
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Tests for the subprocess executor."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from unittest.mock import AsyncMock, patch
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from docketeer.executor import Mount
|
|
11
|
+
from docketeer_subprocess import SubprocessExecutor, create_executor
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_create_executor():
|
|
15
|
+
executor = create_executor()
|
|
16
|
+
assert isinstance(executor, SubprocessExecutor)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture()
|
|
20
|
+
def mock_process() -> AsyncMock:
|
|
21
|
+
proc = AsyncMock(spec=asyncio.subprocess.Process)
|
|
22
|
+
proc.pid = 42
|
|
23
|
+
proc.stdin = AsyncMock()
|
|
24
|
+
proc.stdout = AsyncMock()
|
|
25
|
+
proc.stderr = AsyncMock()
|
|
26
|
+
proc.returncode = 0
|
|
27
|
+
proc.communicate = AsyncMock(return_value=(b"out", b"err"))
|
|
28
|
+
return proc
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# --- start() ---
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def test_start_runs_command(mock_process: AsyncMock):
|
|
35
|
+
executor = SubprocessExecutor()
|
|
36
|
+
with patch("docketeer_subprocess.executor.asyncio") as mock_asyncio:
|
|
37
|
+
mock_asyncio.subprocess = asyncio.subprocess
|
|
38
|
+
mock_asyncio.create_subprocess_exec = AsyncMock(return_value=mock_process)
|
|
39
|
+
|
|
40
|
+
rp = await executor.start(["echo", "hello"])
|
|
41
|
+
|
|
42
|
+
mock_asyncio.create_subprocess_exec.assert_called_once()
|
|
43
|
+
call_args = mock_asyncio.create_subprocess_exec.call_args
|
|
44
|
+
assert call_args[0] == ("echo", "hello")
|
|
45
|
+
assert rp.pid == 42
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def test_start_merges_env(mock_process: AsyncMock):
|
|
49
|
+
executor = SubprocessExecutor()
|
|
50
|
+
with patch("docketeer_subprocess.executor.asyncio") as mock_asyncio:
|
|
51
|
+
mock_asyncio.subprocess = asyncio.subprocess
|
|
52
|
+
mock_asyncio.create_subprocess_exec = AsyncMock(return_value=mock_process)
|
|
53
|
+
|
|
54
|
+
await executor.start(["env"], env={"MY_VAR": "test_value"})
|
|
55
|
+
|
|
56
|
+
call_kwargs = mock_asyncio.create_subprocess_exec.call_args[1]
|
|
57
|
+
assert call_kwargs["env"]["MY_VAR"] == "test_value"
|
|
58
|
+
assert "PATH" in call_kwargs["env"]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def test_start_uses_first_mount_as_cwd(mock_process: AsyncMock, tmp_path: Path):
|
|
62
|
+
executor = SubprocessExecutor()
|
|
63
|
+
mounts = [
|
|
64
|
+
Mount(source=tmp_path, target=Path("/workspace"), writable=True),
|
|
65
|
+
Mount(source=Path("/other"), target=Path("/mnt/other")),
|
|
66
|
+
]
|
|
67
|
+
with patch("docketeer_subprocess.executor.asyncio") as mock_asyncio:
|
|
68
|
+
mock_asyncio.subprocess = asyncio.subprocess
|
|
69
|
+
mock_asyncio.create_subprocess_exec = AsyncMock(return_value=mock_process)
|
|
70
|
+
|
|
71
|
+
await executor.start(["ls"], mounts=mounts)
|
|
72
|
+
|
|
73
|
+
call_kwargs = mock_asyncio.create_subprocess_exec.call_args[1]
|
|
74
|
+
assert call_kwargs["cwd"] == tmp_path
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def test_start_no_cwd_without_mounts(mock_process: AsyncMock):
|
|
78
|
+
executor = SubprocessExecutor()
|
|
79
|
+
with patch("docketeer_subprocess.executor.asyncio") as mock_asyncio:
|
|
80
|
+
mock_asyncio.subprocess = asyncio.subprocess
|
|
81
|
+
mock_asyncio.create_subprocess_exec = AsyncMock(return_value=mock_process)
|
|
82
|
+
|
|
83
|
+
await executor.start(["ls"])
|
|
84
|
+
|
|
85
|
+
call_kwargs = mock_asyncio.create_subprocess_exec.call_args[1]
|
|
86
|
+
assert call_kwargs["cwd"] is None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def test_start_ignores_network_and_username(mock_process: AsyncMock):
|
|
90
|
+
executor = SubprocessExecutor()
|
|
91
|
+
with patch("docketeer_subprocess.executor.asyncio") as mock_asyncio:
|
|
92
|
+
mock_asyncio.subprocess = asyncio.subprocess
|
|
93
|
+
mock_asyncio.create_subprocess_exec = AsyncMock(return_value=mock_process)
|
|
94
|
+
|
|
95
|
+
rp = await executor.start(
|
|
96
|
+
["echo", "hi"],
|
|
97
|
+
network_access=True,
|
|
98
|
+
username="someone",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
result = await rp.wait()
|
|
102
|
+
assert result.returncode == 0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def test_start_env_overrides_inherited(mock_process: AsyncMock):
|
|
106
|
+
executor = SubprocessExecutor()
|
|
107
|
+
with patch.dict(os.environ, {"PATH": "/original"}):
|
|
108
|
+
with patch("docketeer_subprocess.executor.asyncio") as mock_asyncio:
|
|
109
|
+
mock_asyncio.subprocess = asyncio.subprocess
|
|
110
|
+
mock_asyncio.create_subprocess_exec = AsyncMock(return_value=mock_process)
|
|
111
|
+
|
|
112
|
+
await executor.start(["ls"], env={"PATH": "/custom"})
|
|
113
|
+
|
|
114
|
+
call_kwargs = mock_asyncio.create_subprocess_exec.call_args[1]
|
|
115
|
+
assert call_kwargs["env"]["PATH"] == "/custom"
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Tests for the MCP bridge relay function."""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import socket
|
|
5
|
+
import threading
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from unittest.mock import MagicMock, patch
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from docketeer_subprocess.mcp_bridge import main, relay
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_relay_forwards_stdin_to_socket(tmp_path: Path):
|
|
15
|
+
"""Data written to stdin arrives on the socket server."""
|
|
16
|
+
socket_path = tmp_path / "test.sock"
|
|
17
|
+
|
|
18
|
+
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
19
|
+
server.bind(str(socket_path))
|
|
20
|
+
server.listen(1)
|
|
21
|
+
|
|
22
|
+
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
23
|
+
client.connect(str(socket_path))
|
|
24
|
+
|
|
25
|
+
conn, _ = server.accept()
|
|
26
|
+
|
|
27
|
+
stdin = io.BytesIO(b'{"method":"ping"}\n')
|
|
28
|
+
stdout = io.BytesIO()
|
|
29
|
+
|
|
30
|
+
def accept_and_close() -> None:
|
|
31
|
+
data = conn.recv(4096)
|
|
32
|
+
received.append(data)
|
|
33
|
+
conn.close()
|
|
34
|
+
|
|
35
|
+
received: list[bytes] = []
|
|
36
|
+
t = threading.Thread(target=accept_and_close)
|
|
37
|
+
t.start()
|
|
38
|
+
|
|
39
|
+
relay(client, stdin, stdout)
|
|
40
|
+
|
|
41
|
+
t.join(timeout=5)
|
|
42
|
+
server.close()
|
|
43
|
+
|
|
44
|
+
assert received[0] == b'{"method":"ping"}\n'
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_relay_forwards_socket_to_stdout(tmp_path: Path):
|
|
48
|
+
"""Data sent by the socket server arrives on stdout."""
|
|
49
|
+
socket_path = tmp_path / "test.sock"
|
|
50
|
+
|
|
51
|
+
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
52
|
+
server.bind(str(socket_path))
|
|
53
|
+
server.listen(1)
|
|
54
|
+
|
|
55
|
+
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
56
|
+
client.connect(str(socket_path))
|
|
57
|
+
|
|
58
|
+
conn, _ = server.accept()
|
|
59
|
+
|
|
60
|
+
stdin = io.BytesIO(b"") # EOF immediately
|
|
61
|
+
stdout = io.BytesIO()
|
|
62
|
+
|
|
63
|
+
conn.sendall(b'{"result":"ok"}\n')
|
|
64
|
+
conn.close()
|
|
65
|
+
|
|
66
|
+
relay(client, stdin, stdout)
|
|
67
|
+
server.close()
|
|
68
|
+
|
|
69
|
+
assert stdout.getvalue() == b'{"result":"ok"}\n'
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_relay_exits_when_socket_closes(tmp_path: Path):
|
|
73
|
+
"""relay() returns when the server closes the connection."""
|
|
74
|
+
socket_path = tmp_path / "test.sock"
|
|
75
|
+
|
|
76
|
+
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
77
|
+
server.bind(str(socket_path))
|
|
78
|
+
server.listen(1)
|
|
79
|
+
|
|
80
|
+
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
81
|
+
client.connect(str(socket_path))
|
|
82
|
+
|
|
83
|
+
conn, _ = server.accept()
|
|
84
|
+
conn.close()
|
|
85
|
+
|
|
86
|
+
stdin = io.BytesIO(b"")
|
|
87
|
+
stdout = io.BytesIO()
|
|
88
|
+
|
|
89
|
+
relay(client, stdin, stdout)
|
|
90
|
+
server.close()
|
|
91
|
+
|
|
92
|
+
assert stdout.getvalue() == b""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_relay_handles_broken_pipe_on_send(tmp_path: Path):
|
|
96
|
+
"""stdin_to_socket catches BrokenPipeError when the socket is closed."""
|
|
97
|
+
socket_path = tmp_path / "test.sock"
|
|
98
|
+
|
|
99
|
+
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
100
|
+
server.bind(str(socket_path))
|
|
101
|
+
server.listen(1)
|
|
102
|
+
|
|
103
|
+
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
104
|
+
client.connect(str(socket_path))
|
|
105
|
+
|
|
106
|
+
conn, _ = server.accept()
|
|
107
|
+
|
|
108
|
+
conn.close()
|
|
109
|
+
|
|
110
|
+
stdin = io.BytesIO(b'{"method":"ping"}\n')
|
|
111
|
+
stdout = io.BytesIO()
|
|
112
|
+
|
|
113
|
+
relay(client, stdin, stdout)
|
|
114
|
+
server.close()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_relay_handles_recv_error():
|
|
118
|
+
"""Main recv loop catches OSError when the socket errors mid-stream."""
|
|
119
|
+
mock_sock = MagicMock(spec=socket.socket)
|
|
120
|
+
mock_sock.recv.side_effect = OSError("connection reset")
|
|
121
|
+
|
|
122
|
+
stdin = io.BytesIO(b"")
|
|
123
|
+
stdout = io.BytesIO()
|
|
124
|
+
|
|
125
|
+
relay(mock_sock, stdin, stdout)
|
|
126
|
+
mock_sock.close.assert_called_once()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_relay_handles_shutdown_error_after_stdin_eof():
|
|
130
|
+
"""stdin_to_socket catches OSError if shutdown(SHUT_WR) fails."""
|
|
131
|
+
mock_sock = MagicMock(spec=socket.socket)
|
|
132
|
+
mock_sock.shutdown.side_effect = OSError("already closed")
|
|
133
|
+
mock_sock.recv.return_value = b""
|
|
134
|
+
|
|
135
|
+
stdin = io.BytesIO(b"")
|
|
136
|
+
stdout = io.BytesIO()
|
|
137
|
+
|
|
138
|
+
relay(mock_sock, stdin, stdout)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_main_requires_socket_argument():
|
|
142
|
+
"""main() exits with code 1 when no arguments are provided."""
|
|
143
|
+
with patch("sys.argv", ["mcp_bridge.py"]):
|
|
144
|
+
with pytest.raises(SystemExit) as exc_info:
|
|
145
|
+
main()
|
|
146
|
+
assert exc_info.value.code == 1
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_main_connects_and_relays(tmp_path: Path):
|
|
150
|
+
"""main() connects to the socket and relays data."""
|
|
151
|
+
socket_path = tmp_path / "test.sock"
|
|
152
|
+
|
|
153
|
+
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
154
|
+
server.bind(str(socket_path))
|
|
155
|
+
server.listen(1)
|
|
156
|
+
|
|
157
|
+
def server_handler() -> None:
|
|
158
|
+
conn, _ = server.accept()
|
|
159
|
+
conn.sendall(b'{"result":"ok"}\n')
|
|
160
|
+
conn.close()
|
|
161
|
+
|
|
162
|
+
t = threading.Thread(target=server_handler)
|
|
163
|
+
t.start()
|
|
164
|
+
|
|
165
|
+
stdin_mock = MagicMock()
|
|
166
|
+
stdin_mock.buffer = io.BytesIO(b"")
|
|
167
|
+
stdout_mock = MagicMock()
|
|
168
|
+
stdout_buf = io.BytesIO()
|
|
169
|
+
stdout_mock.buffer = stdout_buf
|
|
170
|
+
|
|
171
|
+
with (
|
|
172
|
+
patch("sys.argv", ["mcp_bridge.py", str(socket_path)]),
|
|
173
|
+
patch("docketeer_subprocess.mcp_bridge.sys") as mock_sys,
|
|
174
|
+
):
|
|
175
|
+
mock_sys.argv = ["mcp_bridge.py", str(socket_path)]
|
|
176
|
+
mock_sys.stdin.buffer = io.BytesIO(b"")
|
|
177
|
+
mock_sys.stdout.buffer = stdout_buf
|
|
178
|
+
mock_sys.stderr = MagicMock()
|
|
179
|
+
main()
|
|
180
|
+
|
|
181
|
+
t.join(timeout=5)
|
|
182
|
+
server.close()
|
|
183
|
+
|
|
184
|
+
assert stdout_buf.getvalue() == b'{"result":"ok"}\n'
|