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.
@@ -0,0 +1,12 @@
1
+ .venv/
2
+ .envrc.private
3
+ .claude/settings.local.json
4
+ __pycache__/
5
+ *.pyc
6
+ *.egg-info/
7
+ dist/
8
+ build/
9
+ workspace/
10
+ .coverage
11
+ .loq_cache
12
+ .python-version
@@ -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,8 @@
1
+ from docketeer_subprocess.executor import SubprocessExecutor
2
+
3
+
4
+ def create_executor() -> SubprocessExecutor:
5
+ return SubprocessExecutor()
6
+
7
+
8
+ __all__ = ["SubprocessExecutor", "create_executor"]
@@ -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'