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.
Files changed (48) hide show
  1. kilntainers/__init__.py +3 -0
  2. kilntainers/__main__.py +6 -0
  3. kilntainers/auth.py +34 -0
  4. kilntainers/backends/__init__.py +17 -0
  5. kilntainers/backends/base.py +380 -0
  6. kilntainers/backends/docker.py +1498 -0
  7. kilntainers/backends/test___init__.py +37 -0
  8. kilntainers/backends/test_base.py +355 -0
  9. kilntainers/backends/test_docker.py +841 -0
  10. kilntainers/backends/test_docker_integration.py +310 -0
  11. kilntainers/backends/test_docker_management.py +122 -0
  12. kilntainers/backends/test_utils.py +152 -0
  13. kilntainers/backends/test_virtual_docker.py +387 -0
  14. kilntainers/cli.py +375 -0
  15. kilntainers/computers.py +284 -0
  16. kilntainers/config.py +71 -0
  17. kilntainers/dashboard.html +4263 -0
  18. kilntainers/dashboard.py +23 -0
  19. kilntainers/desktop.py +47 -0
  20. kilntainers/desktop_control.py +105 -0
  21. kilntainers/desktop_image/Dockerfile +67 -0
  22. kilntainers/desktop_image/desktop-control.py +661 -0
  23. kilntainers/desktop_image/start-desktop.sh +206 -0
  24. kilntainers/desktop_image/visual-action.py +185 -0
  25. kilntainers/desktop_image/wsproxy.py +218 -0
  26. kilntainers/errors.py +36 -0
  27. kilntainers/file_tools.py +320 -0
  28. kilntainers/server.py +1760 -0
  29. kilntainers/test_cli.py +704 -0
  30. kilntainers/test_cli_integration.py +213 -0
  31. kilntainers/test_computers.py +122 -0
  32. kilntainers/test_config.py +135 -0
  33. kilntainers/test_dashboard.py +113 -0
  34. kilntainers/test_desktop_control.py +109 -0
  35. kilntainers/test_e2e_mcp.py +310 -0
  36. kilntainers/test_errors.py +62 -0
  37. kilntainers/test_file_tools.py +175 -0
  38. kilntainers/test_http_lifecycle.py +353 -0
  39. kilntainers/test_lifecycle_integration.py +389 -0
  40. kilntainers/test_server.py +849 -0
  41. kilntainers/test_windows_docker.py +300 -0
  42. kilntainers/windows_docker.py +565 -0
  43. mcp_virtual_computer-0.2.3.dist-info/METADATA +179 -0
  44. mcp_virtual_computer-0.2.3.dist-info/RECORD +48 -0
  45. mcp_virtual_computer-0.2.3.dist-info/WHEEL +5 -0
  46. mcp_virtual_computer-0.2.3.dist-info/entry_points.txt +5 -0
  47. mcp_virtual_computer-0.2.3.dist-info/licenses/LICENSE +7 -0
  48. 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
@@ -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()