mcp-virtual-computer 0.2.3__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.
- kilntainers/__init__.py +3 -0
- kilntainers/__main__.py +6 -0
- kilntainers/auth.py +34 -0
- kilntainers/backends/__init__.py +17 -0
- kilntainers/backends/base.py +380 -0
- kilntainers/backends/docker.py +1498 -0
- kilntainers/backends/test___init__.py +37 -0
- kilntainers/backends/test_base.py +355 -0
- kilntainers/backends/test_docker.py +841 -0
- kilntainers/backends/test_docker_integration.py +310 -0
- kilntainers/backends/test_docker_management.py +122 -0
- kilntainers/backends/test_utils.py +152 -0
- kilntainers/backends/test_virtual_docker.py +387 -0
- kilntainers/cli.py +375 -0
- kilntainers/computers.py +284 -0
- kilntainers/config.py +71 -0
- kilntainers/dashboard.html +4263 -0
- kilntainers/dashboard.py +23 -0
- kilntainers/desktop.py +47 -0
- kilntainers/desktop_control.py +105 -0
- kilntainers/desktop_image/Dockerfile +67 -0
- kilntainers/desktop_image/desktop-control.py +661 -0
- kilntainers/desktop_image/start-desktop.sh +206 -0
- kilntainers/desktop_image/visual-action.py +185 -0
- kilntainers/desktop_image/wsproxy.py +218 -0
- kilntainers/errors.py +36 -0
- kilntainers/file_tools.py +320 -0
- kilntainers/server.py +1760 -0
- kilntainers/test_cli.py +704 -0
- kilntainers/test_cli_integration.py +213 -0
- kilntainers/test_computers.py +122 -0
- kilntainers/test_config.py +135 -0
- kilntainers/test_dashboard.py +113 -0
- kilntainers/test_desktop_control.py +109 -0
- kilntainers/test_e2e_mcp.py +310 -0
- kilntainers/test_errors.py +62 -0
- kilntainers/test_file_tools.py +175 -0
- kilntainers/test_http_lifecycle.py +353 -0
- kilntainers/test_lifecycle_integration.py +389 -0
- kilntainers/test_server.py +849 -0
- kilntainers/test_windows_docker.py +300 -0
- kilntainers/windows_docker.py +565 -0
- mcp_virtual_computer-0.2.3.dist-info/METADATA +179 -0
- mcp_virtual_computer-0.2.3.dist-info/RECORD +48 -0
- mcp_virtual_computer-0.2.3.dist-info/WHEEL +5 -0
- mcp_virtual_computer-0.2.3.dist-info/entry_points.txt +5 -0
- mcp_virtual_computer-0.2.3.dist-info/licenses/LICENSE +7 -0
- mcp_virtual_computer-0.2.3.dist-info/top_level.txt +1 -0
kilntainers/cli.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
"""CLI argument parsing and main entry point."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import signal
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
from typing import NoReturn
|
|
9
|
+
|
|
10
|
+
from kilntainers.auth import BearerTokenMiddleware
|
|
11
|
+
from kilntainers.backends import (
|
|
12
|
+
get_available_backend_names,
|
|
13
|
+
get_backend_class,
|
|
14
|
+
)
|
|
15
|
+
from kilntainers.computers import validate_computer_id
|
|
16
|
+
from kilntainers.config import BackendConfig, ServerConfig, env_flag
|
|
17
|
+
from kilntainers.errors import BackendError
|
|
18
|
+
from kilntainers.server import create_server
|
|
19
|
+
|
|
20
|
+
# Sentinel for detecting unset HTTP-only arguments
|
|
21
|
+
_UNSET = object()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
25
|
+
"""Build the CLI argument parser.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
An ArgumentParser with all kilntainers arguments organized into groups.
|
|
29
|
+
"""
|
|
30
|
+
parser = argparse.ArgumentParser(
|
|
31
|
+
prog="mcp-virtual-computer",
|
|
32
|
+
description=(
|
|
33
|
+
"MCP server providing one persistent, visually rendered local "
|
|
34
|
+
"Docker computer for LLM agents."
|
|
35
|
+
),
|
|
36
|
+
usage="%(prog)s [-h] [--transport {stdio,http}] [...]",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Get available backend names for choices
|
|
40
|
+
available_backends = get_available_backend_names()
|
|
41
|
+
|
|
42
|
+
# --- Core parameters ---
|
|
43
|
+
core = parser.add_argument_group("core options")
|
|
44
|
+
core.add_argument(
|
|
45
|
+
"--transport",
|
|
46
|
+
default="stdio",
|
|
47
|
+
choices=["stdio", "http"],
|
|
48
|
+
help="MCP transport (default: stdio)",
|
|
49
|
+
)
|
|
50
|
+
core.add_argument(
|
|
51
|
+
"--host",
|
|
52
|
+
default=_UNSET,
|
|
53
|
+
help="HTTP bind address (default: 127.0.0.1, HTTP mode only)",
|
|
54
|
+
)
|
|
55
|
+
core.add_argument(
|
|
56
|
+
"--port",
|
|
57
|
+
type=int,
|
|
58
|
+
default=_UNSET,
|
|
59
|
+
help="HTTP listen port (default: 8435, HTTP mode only)",
|
|
60
|
+
)
|
|
61
|
+
core.add_argument(
|
|
62
|
+
"--timeout",
|
|
63
|
+
type=int,
|
|
64
|
+
default=120,
|
|
65
|
+
help="Default exec timeout in seconds (default: 120)",
|
|
66
|
+
)
|
|
67
|
+
core.add_argument(
|
|
68
|
+
"--output-limit",
|
|
69
|
+
type=int,
|
|
70
|
+
default=2_097_152,
|
|
71
|
+
help="Max combined stdout+stderr bytes per exec (default: 2097152 = 2 MiB)",
|
|
72
|
+
)
|
|
73
|
+
core.add_argument(
|
|
74
|
+
"--session-timeout",
|
|
75
|
+
type=int,
|
|
76
|
+
default=_UNSET,
|
|
77
|
+
help="Idle session timeout in seconds (default: 300, HTTP mode only)",
|
|
78
|
+
)
|
|
79
|
+
core.add_argument(
|
|
80
|
+
"--auth-token",
|
|
81
|
+
default=os.getenv("KILNTAINERS_AUTH_TOKEN"),
|
|
82
|
+
help=(
|
|
83
|
+
"Static bearer token for the /mcp HTTP route "
|
|
84
|
+
"(default: KILNTAINERS_AUTH_TOKEN)"
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
core.add_argument(
|
|
88
|
+
"--allow-unauthenticated-http",
|
|
89
|
+
action="store_true",
|
|
90
|
+
default=False,
|
|
91
|
+
help=(
|
|
92
|
+
"Allow a non-loopback HTTP listener without authentication. "
|
|
93
|
+
"Only use behind a trusted private network or auth proxy."
|
|
94
|
+
),
|
|
95
|
+
)
|
|
96
|
+
core.add_argument(
|
|
97
|
+
"--shell",
|
|
98
|
+
default="/bin/bash",
|
|
99
|
+
help="Shell binary for command mode (e.g., /bin/bash, ash). Default: /bin/bash.",
|
|
100
|
+
)
|
|
101
|
+
core.add_argument(
|
|
102
|
+
"--network",
|
|
103
|
+
action=argparse.BooleanOptionalAction,
|
|
104
|
+
default=True,
|
|
105
|
+
help="Enable network access in sandboxes (default: enabled)",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# --- Tool description ---
|
|
109
|
+
desc = parser.add_argument_group("tool description")
|
|
110
|
+
desc.add_argument(
|
|
111
|
+
"--tool-instruction-override",
|
|
112
|
+
default=None,
|
|
113
|
+
help="Replace the entire terminal_execute tool description",
|
|
114
|
+
)
|
|
115
|
+
desc.add_argument(
|
|
116
|
+
"--extended-tool-instruction",
|
|
117
|
+
default=None,
|
|
118
|
+
help="Append to the backend's default tool description",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# --- Backend-specific parameters (delegated to each backend) ---
|
|
122
|
+
for name in available_backends:
|
|
123
|
+
group = parser.add_argument_group(f"{name} backend options")
|
|
124
|
+
try:
|
|
125
|
+
backend_cls = get_backend_class(name)
|
|
126
|
+
backend_cls.add_cli_arguments(group)
|
|
127
|
+
except BackendError:
|
|
128
|
+
# Keep --help available if the local Docker adapter cannot initialize.
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
return parser
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def build_configs(
|
|
135
|
+
args: argparse.Namespace,
|
|
136
|
+
) -> tuple[ServerConfig, BackendConfig]:
|
|
137
|
+
"""Build config dataclasses from parsed arguments.
|
|
138
|
+
|
|
139
|
+
This function maps flat CLI arguments to the typed config objects
|
|
140
|
+
consumed by the server and backend layers. Server config is built
|
|
141
|
+
here; backend config is delegated to the backend class.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
args: Parsed command-line arguments from argparse.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
A tuple of (ServerConfig, BackendConfig).
|
|
148
|
+
"""
|
|
149
|
+
# Handle HTTP-only args that may be _UNSET
|
|
150
|
+
host = "127.0.0.1" if args.host is _UNSET else args.host
|
|
151
|
+
port = 8435 if args.port is _UNSET else args.port
|
|
152
|
+
session_timeout = 300 if args.session_timeout is _UNSET else args.session_timeout
|
|
153
|
+
|
|
154
|
+
server_config = ServerConfig(
|
|
155
|
+
transport=args.transport,
|
|
156
|
+
host=host,
|
|
157
|
+
port=port,
|
|
158
|
+
default_timeout=args.timeout,
|
|
159
|
+
output_limit=args.output_limit,
|
|
160
|
+
computer_id=os.getenv("COMPUTER_ID", ""),
|
|
161
|
+
desktop_environment=env_flag("DESKTOP_ENVIRONMENT", default=False),
|
|
162
|
+
network_access=env_flag("NETWORK_ACCESS", default=args.network),
|
|
163
|
+
tool_instruction_override=args.tool_instruction_override,
|
|
164
|
+
extended_tool_instruction=args.extended_tool_instruction,
|
|
165
|
+
session_timeout=session_timeout,
|
|
166
|
+
auth_token=args.auth_token,
|
|
167
|
+
allow_unauthenticated_http=args.allow_unauthenticated_http,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# Delegate backend config construction to the backend class
|
|
171
|
+
backend_cls = get_backend_class("docker")
|
|
172
|
+
backend_config = backend_cls.config_from_args(args)
|
|
173
|
+
|
|
174
|
+
return server_config, backend_config
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _startup_error(message: str) -> NoReturn:
|
|
178
|
+
"""Write an error message to stderr and exit with code 1.
|
|
179
|
+
|
|
180
|
+
Used for all startup/configuration errors.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
message: The error message to display.
|
|
184
|
+
|
|
185
|
+
Raises:
|
|
186
|
+
SystemExit: Always exits with code 1.
|
|
187
|
+
"""
|
|
188
|
+
sys.stderr.write(f"mcp-virtual-computer: error: {message}\n")
|
|
189
|
+
sys.exit(1)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def validate_config(server_config: ServerConfig) -> None:
|
|
193
|
+
"""Validate configuration constraints that span multiple parameters.
|
|
194
|
+
|
|
195
|
+
Raises SystemExit with a descriptive message on failure.
|
|
196
|
+
Individual argument type validation is handled by argparse.
|
|
197
|
+
Cross-cutting constraints are checked here.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
server_config: The server configuration to validate.
|
|
201
|
+
|
|
202
|
+
Raises:
|
|
203
|
+
SystemExit: If validation fails, with code 1 and an error message.
|
|
204
|
+
"""
|
|
205
|
+
# HTTP-only parameters in stdio mode
|
|
206
|
+
# When called from main(), _parsed_args contains the actual parsed args
|
|
207
|
+
# When called from tests, we rely on comparing config values to defaults
|
|
208
|
+
if server_config.transport == "stdio":
|
|
209
|
+
# Check if user explicitly passed HTTP-only args
|
|
210
|
+
# We check if values differ from defaults, indicating explicit setting
|
|
211
|
+
if server_config.host != "127.0.0.1":
|
|
212
|
+
_startup_error(
|
|
213
|
+
"--host is only valid with --transport http. "
|
|
214
|
+
"In stdio mode, there is no HTTP server to bind."
|
|
215
|
+
)
|
|
216
|
+
if server_config.port != 8435:
|
|
217
|
+
_startup_error(
|
|
218
|
+
"--port is only valid with --transport http. "
|
|
219
|
+
"In stdio mode, there is no HTTP server to bind."
|
|
220
|
+
)
|
|
221
|
+
if server_config.session_timeout != 300:
|
|
222
|
+
_startup_error(
|
|
223
|
+
"--session-timeout is only valid with --transport http. "
|
|
224
|
+
"In stdio mode, the session lives as long as the process."
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
# Mutual exclusivity: tool description params
|
|
228
|
+
if (
|
|
229
|
+
server_config.tool_instruction_override is not None
|
|
230
|
+
and server_config.extended_tool_instruction is not None
|
|
231
|
+
):
|
|
232
|
+
_startup_error(
|
|
233
|
+
"Cannot use both --tool-instruction-override and "
|
|
234
|
+
"--extended-tool-instruction. Use override to replace "
|
|
235
|
+
"the description entirely, or extended to append to "
|
|
236
|
+
"the backend default."
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
# Timeout must be positive
|
|
240
|
+
if server_config.default_timeout < 1:
|
|
241
|
+
_startup_error("--timeout must be at least 1 second.")
|
|
242
|
+
|
|
243
|
+
# Output limit must be positive
|
|
244
|
+
if server_config.output_limit < 1:
|
|
245
|
+
_startup_error("--output-limit must be at least 1 byte.")
|
|
246
|
+
|
|
247
|
+
if not server_config.computer_id:
|
|
248
|
+
_startup_error("COMPUTER_ID is required (example: COMPUTER_ID=agent-workstation).")
|
|
249
|
+
try:
|
|
250
|
+
validate_computer_id(server_config.computer_id)
|
|
251
|
+
except BackendError as error:
|
|
252
|
+
_startup_error(str(error).replace("computer_id", "COMPUTER_ID"))
|
|
253
|
+
|
|
254
|
+
if (
|
|
255
|
+
server_config.transport == "http"
|
|
256
|
+
and server_config.host not in {"127.0.0.1", "localhost", "::1"}
|
|
257
|
+
and not server_config.auth_token
|
|
258
|
+
and not server_config.allow_unauthenticated_http
|
|
259
|
+
):
|
|
260
|
+
_startup_error(
|
|
261
|
+
"A non-loopback HTTP listener can execute arbitrary sandbox commands. "
|
|
262
|
+
"Set KILNTAINERS_AUTH_TOKEN/--auth-token, or explicitly pass "
|
|
263
|
+
"--allow-unauthenticated-http behind a trusted private network."
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
async def _async_main(
|
|
268
|
+
server_config: ServerConfig,
|
|
269
|
+
backend_config: BackendConfig,
|
|
270
|
+
backend_name: str,
|
|
271
|
+
) -> None:
|
|
272
|
+
"""Async startup: build server, run.
|
|
273
|
+
|
|
274
|
+
This function performs all async startup operations:
|
|
275
|
+
- Creates the backend (validation happens lazily)
|
|
276
|
+
- Creates the MCP server
|
|
277
|
+
- Runs the transport (blocking until shutdown)
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
server_config: Server configuration.
|
|
281
|
+
backend_config: Backend configuration.
|
|
282
|
+
backend_name: Name of the backend to use.
|
|
283
|
+
|
|
284
|
+
Raises:
|
|
285
|
+
SystemExit: If server creation fails.
|
|
286
|
+
"""
|
|
287
|
+
# Create backend (validation happens lazily on first terminal_execute)
|
|
288
|
+
backend_class = get_backend_class(backend_name)
|
|
289
|
+
backend = backend_class(backend_config)
|
|
290
|
+
|
|
291
|
+
# Create the MCP server (assembles tool description, registers tool)
|
|
292
|
+
try:
|
|
293
|
+
mcp = create_server(backend, server_config)
|
|
294
|
+
except BackendError as e:
|
|
295
|
+
_startup_error(str(e))
|
|
296
|
+
|
|
297
|
+
# Run the transport (blocks until shutdown)
|
|
298
|
+
transport = "stdio" if server_config.transport == "stdio" else "streamable-http"
|
|
299
|
+
mcp.run(transport=transport)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def main() -> None:
|
|
303
|
+
"""CLI entry point. Parses args, configures, and runs the server.
|
|
304
|
+
|
|
305
|
+
This is the main entry point for the kilntainers command. It:
|
|
306
|
+
1. Parses CLI arguments
|
|
307
|
+
2. Builds configuration objects
|
|
308
|
+
3. Validates configuration constraints
|
|
309
|
+
4. Creates the backend (validation happens lazily on first exec)
|
|
310
|
+
5. Creates and runs the MCP server
|
|
311
|
+
|
|
312
|
+
Never returns normally (exits on KeyboardInterrupt or server shutdown).
|
|
313
|
+
"""
|
|
314
|
+
parser = build_parser()
|
|
315
|
+
args = parser.parse_args()
|
|
316
|
+
|
|
317
|
+
server_config, backend_config = build_configs(args)
|
|
318
|
+
validate_config(server_config)
|
|
319
|
+
|
|
320
|
+
# Create backend (validation happens lazily on first terminal_execute)
|
|
321
|
+
backend_name = "docker"
|
|
322
|
+
backend_class = get_backend_class(backend_name)
|
|
323
|
+
backend_class.prepare_runtime()
|
|
324
|
+
backend = backend_class(backend_config)
|
|
325
|
+
|
|
326
|
+
# Create the MCP server (assembles tool description, registers tool)
|
|
327
|
+
try:
|
|
328
|
+
mcp = create_server(backend, server_config)
|
|
329
|
+
except BackendError as e:
|
|
330
|
+
_startup_error(str(e))
|
|
331
|
+
|
|
332
|
+
# Run the transport (blocks until shutdown)
|
|
333
|
+
# mcp.run() manages its own event loop, so we call it directly
|
|
334
|
+
transport = "stdio" if server_config.transport == "stdio" else "streamable-http"
|
|
335
|
+
|
|
336
|
+
# Register SIGTERM handler to convert to SIGINT for clean shutdown
|
|
337
|
+
# FastMCP handles SIGINT (Ctrl+C) gracefully, so we redirect SIGTERM to the same path
|
|
338
|
+
def _handle_sigterm(signum: int, frame: object) -> None:
|
|
339
|
+
"""
|
|
340
|
+
Convert SIGTERM to SIGINT for clean shutdown (triggers mcp library graceful shutdown).
|
|
341
|
+
|
|
342
|
+
Watchdog timmer needed as `mcp` library doesn't exit on SIGTERM.
|
|
343
|
+
|
|
344
|
+
`mcp` library is adding sigterm support, but not in a release yet.
|
|
345
|
+
"""
|
|
346
|
+
# Schedule forced exit as fallback in case graceful shutdown hangs.
|
|
347
|
+
# Uses a daemon thread so it won't block normal exit if shutdown succeeds.
|
|
348
|
+
timer = threading.Timer(5.0, lambda: os._exit(0))
|
|
349
|
+
timer.daemon = True
|
|
350
|
+
timer.start()
|
|
351
|
+
os.kill(os.getpid(), signal.SIGINT)
|
|
352
|
+
|
|
353
|
+
signal.signal(signal.SIGTERM, _handle_sigterm)
|
|
354
|
+
if hasattr(signal, "SIGBREAK"):
|
|
355
|
+
signal.signal(signal.SIGBREAK, _handle_sigterm)
|
|
356
|
+
|
|
357
|
+
try:
|
|
358
|
+
if server_config.transport == "http" and server_config.auth_token:
|
|
359
|
+
import uvicorn
|
|
360
|
+
|
|
361
|
+
app = mcp.streamable_http_app()
|
|
362
|
+
app.add_middleware(
|
|
363
|
+
BearerTokenMiddleware, # ty: ignore[invalid-argument-type]
|
|
364
|
+
token=server_config.auth_token,
|
|
365
|
+
)
|
|
366
|
+
uvicorn.run(
|
|
367
|
+
app,
|
|
368
|
+
host=server_config.host,
|
|
369
|
+
port=server_config.port,
|
|
370
|
+
log_level="info",
|
|
371
|
+
)
|
|
372
|
+
else:
|
|
373
|
+
mcp.run(transport=transport)
|
|
374
|
+
except KeyboardInterrupt:
|
|
375
|
+
pass # Clean exit on Ctrl+C
|
kilntainers/computers.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""Named computer registry shared by MCP sessions and dashboard tools."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import re
|
|
5
|
+
import secrets
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from kilntainers.backends.base import Backend, ComputerInfo, Sandbox
|
|
9
|
+
from kilntainers.errors import BackendError
|
|
10
|
+
|
|
11
|
+
_COMPUTER_ID_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
|
|
12
|
+
_ADJECTIVES = (
|
|
13
|
+
"amber",
|
|
14
|
+
"brisk",
|
|
15
|
+
"calm",
|
|
16
|
+
"clever",
|
|
17
|
+
"coral",
|
|
18
|
+
"crisp",
|
|
19
|
+
"gentle",
|
|
20
|
+
"lucky",
|
|
21
|
+
"quiet",
|
|
22
|
+
"rapid",
|
|
23
|
+
"silver",
|
|
24
|
+
"steady",
|
|
25
|
+
)
|
|
26
|
+
_NOUNS = (
|
|
27
|
+
"badger",
|
|
28
|
+
"comet",
|
|
29
|
+
"falcon",
|
|
30
|
+
"gecko",
|
|
31
|
+
"heron",
|
|
32
|
+
"lynx",
|
|
33
|
+
"otter",
|
|
34
|
+
"panda",
|
|
35
|
+
"raven",
|
|
36
|
+
"tiger",
|
|
37
|
+
"whale",
|
|
38
|
+
"wolf",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def random_computer_id() -> str:
|
|
43
|
+
"""Generate a readable, collision-resistant provider-safe slug."""
|
|
44
|
+
return (
|
|
45
|
+
f"{secrets.choice(_ADJECTIVES)}-{secrets.choice(_NOUNS)}-{secrets.token_hex(2)}"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def validate_computer_id(computer_id: str) -> str:
|
|
50
|
+
"""Validate and return a stable computer ID.
|
|
51
|
+
|
|
52
|
+
IDs intentionally use a conservative lowercase Docker-safe slug format.
|
|
53
|
+
"""
|
|
54
|
+
value = computer_id.strip()
|
|
55
|
+
if not _COMPUTER_ID_RE.fullmatch(value):
|
|
56
|
+
raise BackendError(
|
|
57
|
+
"computer_id must be 1-63 lowercase letters, numbers, or hyphens; "
|
|
58
|
+
"it must start and end with a letter or number."
|
|
59
|
+
)
|
|
60
|
+
return value
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(slots=True)
|
|
64
|
+
class _ComputerRecord:
|
|
65
|
+
sandbox: Sandbox
|
|
66
|
+
temporary: bool
|
|
67
|
+
owners: int = 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ComputerRegistry:
|
|
71
|
+
"""Coordinate named sandboxes across MCP sessions in one server process.
|
|
72
|
+
|
|
73
|
+
The Docker backend also discovers computers provider-side, so permanent
|
|
74
|
+
records survive a server restart and can be reattached by ID.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(self, backend: Backend) -> None:
|
|
78
|
+
self.backend = backend
|
|
79
|
+
self._records: dict[str, _ComputerRecord] = {}
|
|
80
|
+
self._lock = asyncio.Lock()
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def _tag(sandbox: Sandbox, computer_id: str, temporary: bool) -> Sandbox:
|
|
84
|
+
"""Apply registry metadata used by legacy Sandbox base properties."""
|
|
85
|
+
setattr(sandbox, "_managed_computer_id", computer_id)
|
|
86
|
+
setattr(sandbox, "_managed_temporary", temporary)
|
|
87
|
+
return sandbox
|
|
88
|
+
|
|
89
|
+
async def acquire(
|
|
90
|
+
self,
|
|
91
|
+
computer_id: str | None,
|
|
92
|
+
*,
|
|
93
|
+
temporary: bool,
|
|
94
|
+
add_owner: bool,
|
|
95
|
+
) -> tuple[str, Sandbox]:
|
|
96
|
+
"""Attach to or create a computer and optionally add an owner ref."""
|
|
97
|
+
requested_id = (
|
|
98
|
+
random_computer_id()
|
|
99
|
+
if computer_id is None
|
|
100
|
+
else validate_computer_id(computer_id)
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
async with self._lock:
|
|
104
|
+
record = self._records.get(requested_id)
|
|
105
|
+
if record is not None:
|
|
106
|
+
if record.temporary != temporary:
|
|
107
|
+
raise BackendError(
|
|
108
|
+
f"Computer '{requested_id}' already exists with "
|
|
109
|
+
f"temporary={str(record.temporary).lower()}; lifecycle "
|
|
110
|
+
"mode cannot be changed without a factory reset or delete."
|
|
111
|
+
)
|
|
112
|
+
if add_owner:
|
|
113
|
+
record.owners += 1
|
|
114
|
+
return requested_id, record.sandbox
|
|
115
|
+
|
|
116
|
+
sandbox = await self.backend.attach_sandbox(requested_id)
|
|
117
|
+
if sandbox is not None:
|
|
118
|
+
actual_temporary = sandbox.temporary
|
|
119
|
+
if actual_temporary != temporary:
|
|
120
|
+
raise BackendError(
|
|
121
|
+
f"Computer '{requested_id}' already exists with "
|
|
122
|
+
f"temporary={str(actual_temporary).lower()}; requested "
|
|
123
|
+
f"temporary={str(temporary).lower()}."
|
|
124
|
+
)
|
|
125
|
+
else:
|
|
126
|
+
sandbox = await self.backend.create_sandbox(
|
|
127
|
+
computer_id=requested_id,
|
|
128
|
+
temporary=temporary,
|
|
129
|
+
)
|
|
130
|
+
# Legacy backends keep their original Sandbox interface. These
|
|
131
|
+
# attributes let the base properties expose registry semantics
|
|
132
|
+
# without wrapping the instance or breaking backend-specific APIs.
|
|
133
|
+
self._tag(sandbox, requested_id, temporary)
|
|
134
|
+
actual_temporary = temporary
|
|
135
|
+
|
|
136
|
+
self._records[requested_id] = _ComputerRecord(
|
|
137
|
+
sandbox=sandbox,
|
|
138
|
+
temporary=actual_temporary,
|
|
139
|
+
owners=1 if add_owner else 0,
|
|
140
|
+
)
|
|
141
|
+
return requested_id, sandbox
|
|
142
|
+
|
|
143
|
+
async def get_owned(self, computer_id: str) -> Sandbox | None:
|
|
144
|
+
"""Return a locally attached sandbox without changing owner refs."""
|
|
145
|
+
async with self._lock:
|
|
146
|
+
record = self._records.get(computer_id)
|
|
147
|
+
return record.sandbox if record is not None else None
|
|
148
|
+
|
|
149
|
+
def peek(self, computer_id: str) -> Sandbox | None:
|
|
150
|
+
"""Return a local sandbox for synchronous status properties."""
|
|
151
|
+
record = self._records.get(computer_id)
|
|
152
|
+
return record.sandbox if record is not None else None
|
|
153
|
+
|
|
154
|
+
async def release(self, computer_id: str) -> None:
|
|
155
|
+
"""Release one owner and remove an unowned temporary computer."""
|
|
156
|
+
sandbox: Sandbox | None = None
|
|
157
|
+
async with self._lock:
|
|
158
|
+
record = self._records.get(computer_id)
|
|
159
|
+
if record is None:
|
|
160
|
+
return
|
|
161
|
+
record.owners = max(0, record.owners - 1)
|
|
162
|
+
if record.temporary and record.owners == 0:
|
|
163
|
+
sandbox = record.sandbox
|
|
164
|
+
del self._records[computer_id]
|
|
165
|
+
|
|
166
|
+
if sandbox is not None:
|
|
167
|
+
await sandbox.stop()
|
|
168
|
+
|
|
169
|
+
async def list(self) -> list[ComputerInfo]:
|
|
170
|
+
"""Return a de-duplicated provider and in-process inventory."""
|
|
171
|
+
provider_items = await self.backend.list_computers()
|
|
172
|
+
by_id = {item.computer_id: item for item in provider_items}
|
|
173
|
+
|
|
174
|
+
async with self._lock:
|
|
175
|
+
for computer_id, record in self._records.items():
|
|
176
|
+
by_id.setdefault(
|
|
177
|
+
computer_id,
|
|
178
|
+
ComputerInfo(
|
|
179
|
+
computer_id=computer_id,
|
|
180
|
+
sandbox_id=record.sandbox.sandbox_id,
|
|
181
|
+
backend=self.backend.__class__.__name__,
|
|
182
|
+
state="running",
|
|
183
|
+
temporary=record.temporary,
|
|
184
|
+
),
|
|
185
|
+
)
|
|
186
|
+
return sorted(by_id.values(), key=lambda item: item.computer_id)
|
|
187
|
+
|
|
188
|
+
async def restart(self, computer_id: str) -> Sandbox:
|
|
189
|
+
"""Restart a computer while preserving its filesystem."""
|
|
190
|
+
computer_id = validate_computer_id(computer_id)
|
|
191
|
+
async with self._lock:
|
|
192
|
+
record = self._records.get(computer_id)
|
|
193
|
+
replacement = await self.backend.restart_computer(computer_id)
|
|
194
|
+
if replacement is None:
|
|
195
|
+
if record is None:
|
|
196
|
+
raise BackendError(f"Computer '{computer_id}' was not found.")
|
|
197
|
+
await record.sandbox.stop()
|
|
198
|
+
replacement = await self.backend.create_sandbox(
|
|
199
|
+
computer_id=computer_id,
|
|
200
|
+
temporary=record.temporary,
|
|
201
|
+
)
|
|
202
|
+
self._tag(replacement, computer_id, record.temporary)
|
|
203
|
+
if record is None:
|
|
204
|
+
record = _ComputerRecord(
|
|
205
|
+
replacement,
|
|
206
|
+
replacement.temporary,
|
|
207
|
+
owners=0,
|
|
208
|
+
)
|
|
209
|
+
self._records[computer_id] = record
|
|
210
|
+
else:
|
|
211
|
+
record.sandbox = replacement
|
|
212
|
+
return replacement
|
|
213
|
+
|
|
214
|
+
async def factory_reset(self, computer_id: str) -> Sandbox:
|
|
215
|
+
"""Delete a computer's writable state and recreate its base image."""
|
|
216
|
+
computer_id = validate_computer_id(computer_id)
|
|
217
|
+
async with self._lock:
|
|
218
|
+
record = self._records.get(computer_id)
|
|
219
|
+
replacement = await self.backend.factory_reset_computer(computer_id)
|
|
220
|
+
if replacement is None:
|
|
221
|
+
if record is None:
|
|
222
|
+
raise BackendError(f"Computer '{computer_id}' was not found.")
|
|
223
|
+
await record.sandbox.stop()
|
|
224
|
+
replacement = await self.backend.create_sandbox(
|
|
225
|
+
computer_id=computer_id,
|
|
226
|
+
temporary=record.temporary,
|
|
227
|
+
)
|
|
228
|
+
self._tag(replacement, computer_id, record.temporary)
|
|
229
|
+
if record is None:
|
|
230
|
+
self._records[computer_id] = _ComputerRecord(
|
|
231
|
+
replacement,
|
|
232
|
+
replacement.temporary,
|
|
233
|
+
owners=0,
|
|
234
|
+
)
|
|
235
|
+
else:
|
|
236
|
+
record.sandbox = replacement
|
|
237
|
+
return replacement
|
|
238
|
+
|
|
239
|
+
async def set_network_access(self, computer_id: str, enabled: bool) -> Sandbox:
|
|
240
|
+
"""Change real network access and refresh the attached sandbox handle."""
|
|
241
|
+
computer_id = validate_computer_id(computer_id)
|
|
242
|
+
async with self._lock:
|
|
243
|
+
record = self._records.get(computer_id)
|
|
244
|
+
if record is None:
|
|
245
|
+
raise BackendError(f"Computer '{computer_id}' was not found.")
|
|
246
|
+
replacement = await self.backend.set_network_access(computer_id, enabled)
|
|
247
|
+
if replacement is None:
|
|
248
|
+
raise BackendError("This backend cannot change network access at runtime.")
|
|
249
|
+
self._tag(replacement, computer_id, record.temporary)
|
|
250
|
+
record.sandbox = replacement
|
|
251
|
+
return replacement
|
|
252
|
+
|
|
253
|
+
async def switch_desktop_environment(
|
|
254
|
+
self,
|
|
255
|
+
computer_id: str,
|
|
256
|
+
enabled: bool,
|
|
257
|
+
) -> Sandbox:
|
|
258
|
+
"""Switch virtual/Xfce mode while preserving the whole computer."""
|
|
259
|
+
computer_id = validate_computer_id(computer_id)
|
|
260
|
+
async with self._lock:
|
|
261
|
+
record = self._records.get(computer_id)
|
|
262
|
+
if record is None:
|
|
263
|
+
raise BackendError(f"Computer '{computer_id}' was not found.")
|
|
264
|
+
updated = await self.backend.switch_desktop_environment(
|
|
265
|
+
computer_id,
|
|
266
|
+
enabled,
|
|
267
|
+
)
|
|
268
|
+
if updated is None:
|
|
269
|
+
raise BackendError("This backend cannot switch desktop mode at runtime.")
|
|
270
|
+
self._tag(updated, computer_id, record.temporary)
|
|
271
|
+
record.sandbox = updated
|
|
272
|
+
return updated
|
|
273
|
+
|
|
274
|
+
async def delete(self, computer_id: str) -> None:
|
|
275
|
+
"""Permanently delete a managed computer."""
|
|
276
|
+
computer_id = validate_computer_id(computer_id)
|
|
277
|
+
async with self._lock:
|
|
278
|
+
record = self._records.pop(computer_id, None)
|
|
279
|
+
deleted = await self.backend.delete_computer(computer_id)
|
|
280
|
+
if deleted:
|
|
281
|
+
return
|
|
282
|
+
if record is None:
|
|
283
|
+
raise BackendError(f"Computer '{computer_id}' was not found.")
|
|
284
|
+
await record.sandbox.stop()
|