mcp-as-code 0.1.0__py3-none-any.whl
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.
- maco/__init__.py +3 -0
- maco/_build_info.py +4 -0
- maco/cli.py +258 -0
- maco/codegen.py +680 -0
- maco/config.py +177 -0
- maco/gateway.py +305 -0
- maco/mcp_manager.py +157 -0
- maco/runner.py +104 -0
- maco/sandbox/__init__.py +43 -0
- maco/sandbox/core.py +216 -0
- maco/sandbox/providers/__init__.py +7 -0
- maco/sandbox/providers/base.py +69 -0
- maco/sandbox/providers/docker.py +228 -0
- maco/sandbox/providers/local.py +46 -0
- maco/sandbox/providers/matchlock.py +224 -0
- maco/serve_mcp.py +527 -0
- maco/templates/bash_description.j2 +8 -0
- maco/templates/code_execute_description.j2 +14 -0
- maco/templates/codegen/client.py.j2 +104 -0
- maco/templates/codegen/model.py.j2 +6 -0
- maco/templates/codegen/package_init.py.j2 +2 -0
- maco/templates/codegen/pyproject.toml.j2 +8 -0
- maco/templates/codegen/root_model.py.j2 +3 -0
- maco/templates/codegen/server_init.py.j2 +11 -0
- maco/templates/codegen/tool.py.j2 +38 -0
- maco/templates/codegen/type_alias.py.j2 +2 -0
- maco/templates/serve_mcp_instructions.j2 +17 -0
- maco/templates/server_catalog.j2 +8 -0
- maco/version.py +72 -0
- mcp_as_code-0.1.0.dist-info/METADATA +212 -0
- mcp_as_code-0.1.0.dist-info/RECORD +34 -0
- mcp_as_code-0.1.0.dist-info/WHEEL +4 -0
- mcp_as_code-0.1.0.dist-info/entry_points.txt +2 -0
- mcp_as_code-0.1.0.dist-info/licenses/LICENSE +203 -0
maco/runner.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Run Python scripts with generated MCP wrappers on PYTHONPATH."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RunnerError(RuntimeError):
|
|
14
|
+
"""Raised when maco cannot prepare a code execution run."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def run_code(
|
|
18
|
+
code_path: str | os.PathLike[str],
|
|
19
|
+
script_args: list[str] | None = None,
|
|
20
|
+
*,
|
|
21
|
+
workspace: str | os.PathLike[str] | None = None,
|
|
22
|
+
cwd: str | os.PathLike[str] | None = None,
|
|
23
|
+
python: str | None = None,
|
|
24
|
+
) -> int:
|
|
25
|
+
"""Run a Python script with the generated maco workspace available."""
|
|
26
|
+
|
|
27
|
+
script = Path(code_path).expanduser().resolve()
|
|
28
|
+
if not script.exists():
|
|
29
|
+
raise RunnerError(f"code file not found: {script}")
|
|
30
|
+
workspace_path = find_workspace(script, workspace)
|
|
31
|
+
gateway_file = workspace_path / "gateway.json"
|
|
32
|
+
|
|
33
|
+
env = os.environ.copy()
|
|
34
|
+
env["MACO_WORKSPACE"] = str(workspace_path)
|
|
35
|
+
env["MACO_GATEWAY_FILE"] = str(gateway_file)
|
|
36
|
+
gateway = _read_gateway(gateway_file)
|
|
37
|
+
if gateway.get("url") and not env.get("MACO_GATEWAY_URL"):
|
|
38
|
+
env["MACO_GATEWAY_URL"] = str(gateway["url"])
|
|
39
|
+
if gateway.get("token") and not env.get("MACO_GATEWAY_TOKEN"):
|
|
40
|
+
env["MACO_GATEWAY_TOKEN"] = str(gateway["token"])
|
|
41
|
+
env["PYTHONPATH"] = _prepend_path(str(workspace_path), env.get("PYTHONPATH"))
|
|
42
|
+
|
|
43
|
+
uv = shutil.which("uv")
|
|
44
|
+
if uv is None:
|
|
45
|
+
raise RunnerError("uv is required to run code; install uv or run the script manually with PYTHONPATH set")
|
|
46
|
+
|
|
47
|
+
command = [uv, "run"]
|
|
48
|
+
if python:
|
|
49
|
+
command.extend(["--python", python])
|
|
50
|
+
command.extend([str(script), *(script_args or [])])
|
|
51
|
+
completed = subprocess.run(command, env=env, cwd=str(Path(cwd).resolve()) if cwd else None)
|
|
52
|
+
return completed.returncode
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def find_workspace(
|
|
56
|
+
script: Path,
|
|
57
|
+
explicit_workspace: str | os.PathLike[str] | None = None,
|
|
58
|
+
) -> Path:
|
|
59
|
+
"""Find the generated workspace for a script."""
|
|
60
|
+
|
|
61
|
+
candidates: list[Path] = []
|
|
62
|
+
if explicit_workspace:
|
|
63
|
+
candidates.append(Path(explicit_workspace).expanduser())
|
|
64
|
+
if os.environ.get("MACO_WORKSPACE"):
|
|
65
|
+
candidates.append(Path(os.environ["MACO_WORKSPACE"]).expanduser())
|
|
66
|
+
candidates.extend(parent / ".maco" for parent in [script.parent, *script.parents])
|
|
67
|
+
cwd = Path.cwd().resolve()
|
|
68
|
+
candidates.extend(parent / ".maco" for parent in [cwd, *cwd.parents])
|
|
69
|
+
|
|
70
|
+
seen: set[Path] = set()
|
|
71
|
+
for candidate in candidates:
|
|
72
|
+
resolved = candidate.resolve()
|
|
73
|
+
if resolved in seen:
|
|
74
|
+
continue
|
|
75
|
+
seen.add(resolved)
|
|
76
|
+
if (resolved / "maco_generated" / "client.py").exists():
|
|
77
|
+
return resolved
|
|
78
|
+
raise RunnerError(
|
|
79
|
+
"could not find a generated maco workspace. Run `maco gen`, pass --workspace, "
|
|
80
|
+
"or set MACO_WORKSPACE."
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _read_gateway(path: Path) -> dict[str, object]:
|
|
85
|
+
try:
|
|
86
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
87
|
+
except FileNotFoundError:
|
|
88
|
+
return {}
|
|
89
|
+
except Exception as exc:
|
|
90
|
+
raise RunnerError(f"failed to read gateway file {path}: {exc}") from exc
|
|
91
|
+
if not isinstance(data, dict):
|
|
92
|
+
raise RunnerError(f"gateway file {path} must contain a JSON object")
|
|
93
|
+
return data
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _prepend_path(prefix: str, existing: str | None) -> str:
|
|
97
|
+
if not existing:
|
|
98
|
+
return prefix
|
|
99
|
+
return os.pathsep.join([prefix, existing])
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def exit_with_error(exc: BaseException) -> None:
|
|
103
|
+
print(f"maco: {exc}", file=sys.stderr)
|
|
104
|
+
raise SystemExit(1)
|
maco/sandbox/__init__.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Sandbox providers for maco serve-mcp execution."""
|
|
2
|
+
|
|
3
|
+
from .core import (
|
|
4
|
+
DEFAULT_MATCHLOCK_GATEWAY_IP,
|
|
5
|
+
DEFAULT_SANDBOX_IMAGE,
|
|
6
|
+
GatewayInfo,
|
|
7
|
+
SANDBOX_SDK_ROOT,
|
|
8
|
+
SANDBOX_TOOLS_ROOT,
|
|
9
|
+
SANDBOX_USER,
|
|
10
|
+
SandboxContext,
|
|
11
|
+
SandboxError,
|
|
12
|
+
SandboxExec,
|
|
13
|
+
SandboxProvider,
|
|
14
|
+
SandboxRunResult,
|
|
15
|
+
guest_path_for,
|
|
16
|
+
provider_from_name,
|
|
17
|
+
translate_loopback_url,
|
|
18
|
+
write_code_file,
|
|
19
|
+
)
|
|
20
|
+
from .providers.docker import DockerSandboxProvider
|
|
21
|
+
from .providers.local import LocalSandboxProvider
|
|
22
|
+
from .providers.matchlock import MatchlockSandboxProvider
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"DEFAULT_SANDBOX_IMAGE",
|
|
26
|
+
"DEFAULT_MATCHLOCK_GATEWAY_IP",
|
|
27
|
+
"DockerSandboxProvider",
|
|
28
|
+
"GatewayInfo",
|
|
29
|
+
"LocalSandboxProvider",
|
|
30
|
+
"MatchlockSandboxProvider",
|
|
31
|
+
"SANDBOX_SDK_ROOT",
|
|
32
|
+
"SANDBOX_TOOLS_ROOT",
|
|
33
|
+
"SANDBOX_USER",
|
|
34
|
+
"SandboxContext",
|
|
35
|
+
"SandboxError",
|
|
36
|
+
"SandboxExec",
|
|
37
|
+
"SandboxProvider",
|
|
38
|
+
"SandboxRunResult",
|
|
39
|
+
"guest_path_for",
|
|
40
|
+
"provider_from_name",
|
|
41
|
+
"translate_loopback_url",
|
|
42
|
+
"write_code_file",
|
|
43
|
+
]
|
maco/sandbox/core.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Core sandbox types and provider factory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Mapping, Protocol
|
|
11
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SandboxError(RuntimeError):
|
|
15
|
+
"""Raised when a sandbox provider cannot run a command."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _maco_version() -> str:
|
|
19
|
+
try:
|
|
20
|
+
return version("maco")
|
|
21
|
+
except PackageNotFoundError:
|
|
22
|
+
for parent in Path(__file__).resolve().parents:
|
|
23
|
+
version_file = parent / "VERSION.txt"
|
|
24
|
+
if version_file.exists():
|
|
25
|
+
return version_file.read_text(encoding="utf-8").strip()
|
|
26
|
+
return "0.0.0"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
DEFAULT_SANDBOX_IMAGE = f"ghcr.io/jingkaihe/maco:{_maco_version()}-alpine"
|
|
30
|
+
DEFAULT_MATCHLOCK_GATEWAY_IP = "192.168.100.1"
|
|
31
|
+
SANDBOX_UID = 1000
|
|
32
|
+
SANDBOX_GID = 1000
|
|
33
|
+
SANDBOX_USER = f"{SANDBOX_UID}:{SANDBOX_GID}"
|
|
34
|
+
SANDBOX_SDK_ROOT = "/workspace/macosdk"
|
|
35
|
+
SANDBOX_TOOLS_ROOT = f"{SANDBOX_SDK_ROOT}/tools"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class GatewayInfo:
|
|
40
|
+
"""Host-side maco gateway coordinates."""
|
|
41
|
+
|
|
42
|
+
url: str
|
|
43
|
+
token: str | None = None
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def from_file(cls, path: str | os.PathLike[str]) -> GatewayInfo:
|
|
47
|
+
gateway_path = Path(path).expanduser()
|
|
48
|
+
try:
|
|
49
|
+
payload = json.loads(gateway_path.read_text(encoding="utf-8"))
|
|
50
|
+
except FileNotFoundError as exc:
|
|
51
|
+
raise SandboxError(f"gateway file not found: {gateway_path}") from exc
|
|
52
|
+
except Exception as exc:
|
|
53
|
+
raise SandboxError(f"failed to read gateway file {gateway_path}: {exc}") from exc
|
|
54
|
+
if not isinstance(payload, dict):
|
|
55
|
+
raise SandboxError(f"gateway file {gateway_path} must contain a JSON object")
|
|
56
|
+
url = payload.get("url")
|
|
57
|
+
if not isinstance(url, str) or not url:
|
|
58
|
+
raise SandboxError(f"gateway file {gateway_path} must contain a non-empty url")
|
|
59
|
+
token = payload.get("token")
|
|
60
|
+
return cls(url=url, token=token if isinstance(token, str) and token else None)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class SandboxRunResult:
|
|
65
|
+
"""Result from a sandbox command."""
|
|
66
|
+
|
|
67
|
+
exit_code: int
|
|
68
|
+
stdout: str
|
|
69
|
+
stderr: str
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def ok(self) -> bool:
|
|
73
|
+
return self.exit_code == 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class SandboxContext:
|
|
78
|
+
"""Provider-independent paths and gateway details."""
|
|
79
|
+
|
|
80
|
+
workspace: Path
|
|
81
|
+
scratch: Path
|
|
82
|
+
gateway: GatewayInfo
|
|
83
|
+
timeout: int = 60
|
|
84
|
+
python_command: str | None = None
|
|
85
|
+
debug: bool = False
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class SandboxExec:
|
|
90
|
+
"""Command request for a sandbox provider."""
|
|
91
|
+
|
|
92
|
+
command: str
|
|
93
|
+
timeout: int | None = None
|
|
94
|
+
env: Mapping[str, str] = field(default_factory=dict)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class SandboxProvider(Protocol):
|
|
98
|
+
"""Common execution surface for local, Docker, and Matchlock sandboxes."""
|
|
99
|
+
|
|
100
|
+
guest_workspace: str
|
|
101
|
+
guest_scratch: str
|
|
102
|
+
|
|
103
|
+
def start(self) -> None:
|
|
104
|
+
"""Start or bootstrap provider resources."""
|
|
105
|
+
|
|
106
|
+
def stop(self) -> None:
|
|
107
|
+
"""Release provider resources."""
|
|
108
|
+
|
|
109
|
+
def run(self, request: SandboxExec) -> SandboxRunResult:
|
|
110
|
+
"""Run a non-interactive command in the sandbox."""
|
|
111
|
+
|
|
112
|
+
def write_file(self, relative_path: str, content: str) -> str:
|
|
113
|
+
"""Write a file inside sandbox scratch and return its guest path."""
|
|
114
|
+
|
|
115
|
+
def python_script_command(self, guest_script_path: str, args: list[str]) -> str:
|
|
116
|
+
"""Build the shell command used by code_execute for a guest script."""
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def provider_from_name(
|
|
120
|
+
name: str,
|
|
121
|
+
context: SandboxContext,
|
|
122
|
+
*,
|
|
123
|
+
image: str | None = None,
|
|
124
|
+
docker_binary: str = "docker",
|
|
125
|
+
docker_network: str | None = None,
|
|
126
|
+
docker_gateway_host: str = "host.docker.internal",
|
|
127
|
+
docker_gateway_ip: str | None = None,
|
|
128
|
+
matchlock_binary: str = "matchlock",
|
|
129
|
+
matchlock_gateway_host: str = "maco-gateway.internal",
|
|
130
|
+
matchlock_gateway_ip: str | None = None,
|
|
131
|
+
matchlock_extra_allow_hosts: list[str] | None = None,
|
|
132
|
+
) -> SandboxProvider:
|
|
133
|
+
"""Construct a sandbox provider from a CLI-friendly name."""
|
|
134
|
+
|
|
135
|
+
# Imports are local to avoid importing provider subprocess dependencies when
|
|
136
|
+
# callers only need the data types/helpers.
|
|
137
|
+
from .providers.docker import DockerSandboxProvider
|
|
138
|
+
from .providers.local import LocalSandboxProvider
|
|
139
|
+
from .providers.matchlock import MatchlockSandboxProvider
|
|
140
|
+
|
|
141
|
+
normalized = name.replace("_", "-").lower()
|
|
142
|
+
if normalized == "local":
|
|
143
|
+
return LocalSandboxProvider(context)
|
|
144
|
+
if normalized == "docker":
|
|
145
|
+
return DockerSandboxProvider(
|
|
146
|
+
context,
|
|
147
|
+
image=image or DEFAULT_SANDBOX_IMAGE,
|
|
148
|
+
docker_binary=docker_binary,
|
|
149
|
+
network=docker_network,
|
|
150
|
+
gateway_host=docker_gateway_host,
|
|
151
|
+
gateway_ip=docker_gateway_ip,
|
|
152
|
+
)
|
|
153
|
+
if normalized == "matchlock":
|
|
154
|
+
return MatchlockSandboxProvider(
|
|
155
|
+
context,
|
|
156
|
+
image=image or DEFAULT_SANDBOX_IMAGE,
|
|
157
|
+
matchlock_binary=matchlock_binary,
|
|
158
|
+
gateway_host=matchlock_gateway_host,
|
|
159
|
+
gateway_ip=matchlock_gateway_ip,
|
|
160
|
+
extra_allow_hosts=matchlock_extra_allow_hosts or [],
|
|
161
|
+
)
|
|
162
|
+
raise SandboxError(f"unknown sandbox provider {name!r}; expected local, docker, or matchlock")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def write_code_file(scratch: Path, filename: str, code: str) -> Path:
|
|
166
|
+
"""Write code into the scratch directory after constraining the path."""
|
|
167
|
+
|
|
168
|
+
if not filename.strip():
|
|
169
|
+
raise SandboxError("filename must be non-empty")
|
|
170
|
+
relative = Path(filename)
|
|
171
|
+
if relative.is_absolute() or ".." in relative.parts:
|
|
172
|
+
raise SandboxError("filename must be a relative path inside the sandbox scratch directory")
|
|
173
|
+
path = scratch / relative
|
|
174
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
175
|
+
path.write_text(code, encoding="utf-8")
|
|
176
|
+
return path
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def guest_path_for(local_path: Path, scratch: Path, guest_scratch: str) -> str:
|
|
180
|
+
"""Translate a local scratch path to the corresponding guest path."""
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
relative = local_path.resolve().relative_to(scratch.resolve())
|
|
184
|
+
except ValueError as exc:
|
|
185
|
+
raise SandboxError(f"path {local_path} is not inside scratch directory {scratch}") from exc
|
|
186
|
+
return posix_join(guest_scratch, relative.as_posix())
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def translate_loopback_url(url: str, host: str) -> str:
|
|
190
|
+
"""Replace localhost in a gateway URL with a guest-reachable host alias."""
|
|
191
|
+
|
|
192
|
+
parts = urlsplit(url)
|
|
193
|
+
if parts.hostname not in {"127.0.0.1", "localhost", "::1"}:
|
|
194
|
+
return url
|
|
195
|
+
netloc = host
|
|
196
|
+
if parts.port is not None:
|
|
197
|
+
netloc = f"{host}:{parts.port}"
|
|
198
|
+
return urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def normalize_context(context: SandboxContext) -> SandboxContext:
|
|
202
|
+
workspace = context.workspace.expanduser().resolve()
|
|
203
|
+
scratch = context.scratch.expanduser().resolve()
|
|
204
|
+
scratch.mkdir(parents=True, exist_ok=True)
|
|
205
|
+
return SandboxContext(
|
|
206
|
+
workspace=workspace,
|
|
207
|
+
scratch=scratch,
|
|
208
|
+
gateway=context.gateway,
|
|
209
|
+
timeout=context.timeout,
|
|
210
|
+
python_command=context.python_command,
|
|
211
|
+
debug=context.debug,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def posix_join(root: str, child: str) -> str:
|
|
216
|
+
return f"{root.rstrip('/')}/{child.lstrip('/')}"
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Provider implementations for maco-sandbox."""
|
|
2
|
+
|
|
3
|
+
from .docker import DockerSandboxProvider
|
|
4
|
+
from .local import LocalSandboxProvider
|
|
5
|
+
from .matchlock import MatchlockSandboxProvider
|
|
6
|
+
|
|
7
|
+
__all__ = ["DockerSandboxProvider", "LocalSandboxProvider", "MatchlockSandboxProvider"]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Shared helpers for concrete sandbox providers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import shlex
|
|
7
|
+
from typing import Mapping
|
|
8
|
+
|
|
9
|
+
from ..core import SANDBOX_SDK_ROOT, SandboxContext, SandboxError, SandboxExec, normalize_context, posix_join
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BaseSandboxProvider:
|
|
13
|
+
guest_workspace: str
|
|
14
|
+
guest_scratch: str
|
|
15
|
+
default_python_command = "python"
|
|
16
|
+
|
|
17
|
+
def __init__(self, context: SandboxContext) -> None:
|
|
18
|
+
self.context = normalize_context(context)
|
|
19
|
+
|
|
20
|
+
def start(self) -> None:
|
|
21
|
+
"""Start provider resources. Local/one-shot providers can no-op."""
|
|
22
|
+
|
|
23
|
+
def stop(self) -> None:
|
|
24
|
+
"""Release provider resources. Local/one-shot providers can no-op."""
|
|
25
|
+
|
|
26
|
+
def write_file(self, relative_path: str, content: str) -> str:
|
|
27
|
+
path = self._local_scratch_path(relative_path)
|
|
28
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
path.write_text(content, encoding="utf-8")
|
|
30
|
+
return self._guest_scratch_path(relative_path)
|
|
31
|
+
|
|
32
|
+
def python_script_command(self, guest_script_path: str, args: list[str]) -> str:
|
|
33
|
+
command = self.context.python_command or self.default_python_command
|
|
34
|
+
return " ".join([command, shlex.quote(guest_script_path), *[shlex.quote(arg) for arg in args]])
|
|
35
|
+
|
|
36
|
+
def _timeout(self, request: SandboxExec) -> int:
|
|
37
|
+
return request.timeout or self.context.timeout
|
|
38
|
+
|
|
39
|
+
def _guest_env(self, request_env: Mapping[str, str], *, gateway_url: str) -> dict[str, str]:
|
|
40
|
+
env = {
|
|
41
|
+
"MACO_WORKSPACE": self.guest_workspace,
|
|
42
|
+
"MACO_GATEWAY_FILE": posix_join(self.guest_workspace, "gateway.json"),
|
|
43
|
+
"MACO_GATEWAY_URL": gateway_url,
|
|
44
|
+
"PYTHONPATH": self.guest_workspace,
|
|
45
|
+
}
|
|
46
|
+
if self.context.gateway.token:
|
|
47
|
+
env["MACO_GATEWAY_TOKEN"] = self.context.gateway.token
|
|
48
|
+
env.update(request_env)
|
|
49
|
+
return env
|
|
50
|
+
|
|
51
|
+
def _local_scratch_path(self, relative_path: str) -> Path:
|
|
52
|
+
relative = self._relative_scratch_path(relative_path)
|
|
53
|
+
return self.context.scratch / relative
|
|
54
|
+
|
|
55
|
+
def _guest_scratch_path(self, relative_path: str) -> str:
|
|
56
|
+
return posix_join(self.guest_scratch, self._relative_scratch_path(relative_path).as_posix())
|
|
57
|
+
|
|
58
|
+
def _relative_scratch_path(self, relative_path: str) -> Path:
|
|
59
|
+
if not relative_path.strip():
|
|
60
|
+
raise SandboxError("path must be non-empty")
|
|
61
|
+
relative = Path(relative_path)
|
|
62
|
+
if relative.is_absolute() or ".." in relative.parts:
|
|
63
|
+
raise SandboxError("path must be relative and inside the sandbox scratch directory")
|
|
64
|
+
return relative
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class RemoteSandboxProvider(BaseSandboxProvider):
|
|
68
|
+
guest_workspace = SANDBOX_SDK_ROOT
|
|
69
|
+
guest_scratch = "/workspace"
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Docker sandbox provider."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shlex
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import uuid
|
|
10
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
11
|
+
|
|
12
|
+
from ..core import (
|
|
13
|
+
SANDBOX_SDK_ROOT,
|
|
14
|
+
SANDBOX_USER,
|
|
15
|
+
SandboxContext,
|
|
16
|
+
SandboxError,
|
|
17
|
+
SandboxExec,
|
|
18
|
+
SandboxRunResult,
|
|
19
|
+
translate_loopback_url,
|
|
20
|
+
)
|
|
21
|
+
from .base import RemoteSandboxProvider
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DockerSandboxProvider(RemoteSandboxProvider):
|
|
25
|
+
"""Run commands inside one long-lived Docker container."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
context: SandboxContext,
|
|
30
|
+
*,
|
|
31
|
+
image: str,
|
|
32
|
+
docker_binary: str = "docker",
|
|
33
|
+
network: str | None = None,
|
|
34
|
+
gateway_host: str = "host.docker.internal",
|
|
35
|
+
gateway_ip: str | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
super().__init__(context)
|
|
38
|
+
self.image = image
|
|
39
|
+
self.docker_binary = docker_binary
|
|
40
|
+
self.network = network
|
|
41
|
+
self.gateway_host = gateway_host
|
|
42
|
+
self.gateway_ip = gateway_ip
|
|
43
|
+
self.container_name = self._container_name()
|
|
44
|
+
self.container_id: str | None = None
|
|
45
|
+
self._start_attempted = False
|
|
46
|
+
|
|
47
|
+
def start(self) -> None:
|
|
48
|
+
if self.container_id is not None:
|
|
49
|
+
return
|
|
50
|
+
gateway_url = _docker_gateway_url(
|
|
51
|
+
self.context.gateway.url,
|
|
52
|
+
gateway_host=self.gateway_host,
|
|
53
|
+
gateway_ip=self.gateway_ip,
|
|
54
|
+
)
|
|
55
|
+
env = self._guest_env({}, gateway_url=gateway_url)
|
|
56
|
+
host_env = os.environ.copy()
|
|
57
|
+
command = [
|
|
58
|
+
self.docker_binary,
|
|
59
|
+
"run",
|
|
60
|
+
"-d",
|
|
61
|
+
"--rm",
|
|
62
|
+
"--user",
|
|
63
|
+
SANDBOX_USER,
|
|
64
|
+
"--name",
|
|
65
|
+
self.container_name,
|
|
66
|
+
"--label",
|
|
67
|
+
"org.opencontainers.image.title=maco-sandbox",
|
|
68
|
+
"--label",
|
|
69
|
+
"maco.managed=true",
|
|
70
|
+
]
|
|
71
|
+
if self.network:
|
|
72
|
+
command.extend(["--network", self.network])
|
|
73
|
+
if self.gateway_ip:
|
|
74
|
+
command.extend(["--add-host", f"{self.gateway_host}:{self.gateway_ip}"])
|
|
75
|
+
for key, value in sorted(env.items()):
|
|
76
|
+
if key == "MACO_GATEWAY_TOKEN" and self.context.gateway.token:
|
|
77
|
+
host_env[key] = self.context.gateway.token
|
|
78
|
+
command.extend(["-e", key])
|
|
79
|
+
else:
|
|
80
|
+
command.extend(["-e", f"{key}={value}"])
|
|
81
|
+
command.extend(["-w", self.guest_scratch, self.image])
|
|
82
|
+
self._start_attempted = True
|
|
83
|
+
try:
|
|
84
|
+
completed = subprocess.run(
|
|
85
|
+
command,
|
|
86
|
+
env=host_env,
|
|
87
|
+
text=True,
|
|
88
|
+
stdout=subprocess.PIPE,
|
|
89
|
+
stderr=subprocess.PIPE,
|
|
90
|
+
timeout=self.context.timeout,
|
|
91
|
+
check=False,
|
|
92
|
+
)
|
|
93
|
+
if completed.returncode != 0:
|
|
94
|
+
raise SandboxError(f"failed to start Docker sandbox: {completed.stderr.strip()}")
|
|
95
|
+
self.container_id = completed.stdout.strip()
|
|
96
|
+
self._bootstrap_sdk()
|
|
97
|
+
except BaseException:
|
|
98
|
+
self.stop()
|
|
99
|
+
raise
|
|
100
|
+
|
|
101
|
+
def stop(self) -> None:
|
|
102
|
+
if self.container_id is None and not self._start_attempted:
|
|
103
|
+
return
|
|
104
|
+
target = self.container_id or self.container_name
|
|
105
|
+
try:
|
|
106
|
+
subprocess.run(
|
|
107
|
+
[self.docker_binary, "rm", "-f", target],
|
|
108
|
+
stdout=subprocess.DEVNULL,
|
|
109
|
+
stderr=subprocess.DEVNULL,
|
|
110
|
+
timeout=10,
|
|
111
|
+
check=False,
|
|
112
|
+
)
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
finally:
|
|
116
|
+
self.container_id = None
|
|
117
|
+
self._start_attempted = False
|
|
118
|
+
|
|
119
|
+
def run(self, request: SandboxExec) -> SandboxRunResult:
|
|
120
|
+
self.start()
|
|
121
|
+
assert self.container_id is not None
|
|
122
|
+
command = [
|
|
123
|
+
self.docker_binary,
|
|
124
|
+
"exec",
|
|
125
|
+
"--user",
|
|
126
|
+
SANDBOX_USER,
|
|
127
|
+
"-w",
|
|
128
|
+
self.guest_scratch,
|
|
129
|
+
self.container_id,
|
|
130
|
+
"sh",
|
|
131
|
+
"-lc",
|
|
132
|
+
request.command,
|
|
133
|
+
]
|
|
134
|
+
completed = subprocess.run(
|
|
135
|
+
command,
|
|
136
|
+
text=True,
|
|
137
|
+
stdout=subprocess.PIPE,
|
|
138
|
+
stderr=subprocess.PIPE,
|
|
139
|
+
timeout=self._timeout(request),
|
|
140
|
+
check=False,
|
|
141
|
+
)
|
|
142
|
+
if self.context.debug:
|
|
143
|
+
print(f"maco docker command: {self._command_summary(command)!r}", file=sys.stderr)
|
|
144
|
+
return SandboxRunResult(completed.returncode, completed.stdout, completed.stderr)
|
|
145
|
+
|
|
146
|
+
def write_file(self, relative_path: str, content: str) -> str:
|
|
147
|
+
self.start()
|
|
148
|
+
assert self.container_id is not None
|
|
149
|
+
guest_path = self._guest_scratch_path(relative_path)
|
|
150
|
+
parent = guest_path.rsplit("/", 1)[0]
|
|
151
|
+
command = [
|
|
152
|
+
self.docker_binary,
|
|
153
|
+
"exec",
|
|
154
|
+
"-i",
|
|
155
|
+
"--user",
|
|
156
|
+
SANDBOX_USER,
|
|
157
|
+
self.container_id,
|
|
158
|
+
"sh",
|
|
159
|
+
"-lc",
|
|
160
|
+
f"mkdir -p {shlex.quote(parent)} && cat > {shlex.quote(guest_path)}",
|
|
161
|
+
]
|
|
162
|
+
completed = subprocess.run(
|
|
163
|
+
command,
|
|
164
|
+
input=content,
|
|
165
|
+
text=True,
|
|
166
|
+
stdout=subprocess.PIPE,
|
|
167
|
+
stderr=subprocess.PIPE,
|
|
168
|
+
timeout=self.context.timeout,
|
|
169
|
+
check=False,
|
|
170
|
+
)
|
|
171
|
+
if completed.returncode != 0:
|
|
172
|
+
raise SandboxError(f"failed to write Docker sandbox file {guest_path}: {completed.stderr.strip()}")
|
|
173
|
+
return guest_path
|
|
174
|
+
|
|
175
|
+
def _bootstrap_sdk(self) -> None:
|
|
176
|
+
assert self.container_id is not None
|
|
177
|
+
command = [
|
|
178
|
+
self.docker_binary,
|
|
179
|
+
"exec",
|
|
180
|
+
"--user",
|
|
181
|
+
SANDBOX_USER,
|
|
182
|
+
self.container_id,
|
|
183
|
+
"maco",
|
|
184
|
+
"sandbox-bootstrap",
|
|
185
|
+
"--workspace",
|
|
186
|
+
SANDBOX_SDK_ROOT,
|
|
187
|
+
]
|
|
188
|
+
completed = subprocess.run(
|
|
189
|
+
command,
|
|
190
|
+
text=True,
|
|
191
|
+
stdout=subprocess.PIPE,
|
|
192
|
+
stderr=subprocess.PIPE,
|
|
193
|
+
timeout=self.context.timeout,
|
|
194
|
+
check=False,
|
|
195
|
+
)
|
|
196
|
+
if completed.returncode != 0:
|
|
197
|
+
raise SandboxError(f"failed to bootstrap Docker sandbox SDK: {completed.stderr.strip()}")
|
|
198
|
+
|
|
199
|
+
def _command_summary(self, command: list[str]) -> list[str]:
|
|
200
|
+
redacted: list[str] = []
|
|
201
|
+
for part in command:
|
|
202
|
+
if self.context.gateway.token and part == self.context.gateway.token:
|
|
203
|
+
redacted.append("<redacted>")
|
|
204
|
+
else:
|
|
205
|
+
redacted.append(part)
|
|
206
|
+
return redacted
|
|
207
|
+
|
|
208
|
+
def _container_name(self) -> str:
|
|
209
|
+
return f"maco-sandbox-{uuid.uuid4().hex[:12]}"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _docker_gateway_url(url: str, *, gateway_host: str, gateway_ip: str | None) -> str:
|
|
213
|
+
translated = translate_loopback_url(url, gateway_host)
|
|
214
|
+
if gateway_ip and _url_host(translated) in {gateway_ip, "0.0.0.0"}:
|
|
215
|
+
return _replace_url_host(translated, gateway_host)
|
|
216
|
+
return translated
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _url_host(url: str) -> str | None:
|
|
220
|
+
return urlsplit(url).hostname
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _replace_url_host(url: str, host: str) -> str:
|
|
224
|
+
parts = urlsplit(url)
|
|
225
|
+
netloc = host
|
|
226
|
+
if parts.port is not None:
|
|
227
|
+
netloc = f"{host}:{parts.port}"
|
|
228
|
+
return urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment))
|