mosaic-sandbox 0.1.0__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,67 @@
1
+ Metadata-Version: 2.4
2
+ Name: mosaic-sandbox
3
+ Version: 0.1.0
4
+ Summary: Python SDK for Mosaic Sandbox (MAR)
5
+ Author: Mosaic
6
+ License: Proprietary
7
+ Project-URL: Homepage, https://sandbox.mosaicos.com
8
+ Project-URL: Documentation, https://sandbox.mosaicos.com/docs/
9
+ Project-URL: Repository, https://github.com/nitinrao/Mosaic-Sandbox
10
+ Project-URL: Issues, https://github.com/nitinrao/Mosaic-Sandbox/issues
11
+ Keywords: sandbox,firecracker,microvm,agent,cli,mcp,ssh
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Classifier: Topic :: System :: Distributed Computing
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+
23
+ # Mosaic Sandbox Python SDK
24
+
25
+ `mosaic-sandbox` is the Python SDK and CLI for Mosaic Sandbox, a Firecracker-based
26
+ runtime for coding agents.
27
+
28
+ Install:
29
+
30
+ ```bash
31
+ python -m pip install mosaic-sandbox
32
+ ```
33
+
34
+ Set your endpoint and API token:
35
+
36
+ ```bash
37
+ export MAR_ENDPOINT=https://sandbox.mosaicos.com
38
+ export MAR_API_TOKEN=msk_live_...
39
+ ```
40
+
41
+ Run the CLI smoke test:
42
+
43
+ ```bash
44
+ mar doctor
45
+ mar run --template node-20 node -v
46
+ ```
47
+
48
+ Python example:
49
+
50
+ ```python
51
+ from mosaic_sandbox import Sandbox
52
+
53
+ sbx = Sandbox.create(
54
+ template="node-20",
55
+ endpoint="https://sandbox.mosaicos.com",
56
+ api_token="msk_live_..."
57
+ )
58
+
59
+ try:
60
+ result = sbx.run_command("node -v")
61
+ print(result.stdout)
62
+ print(result.tti_ms)
63
+ finally:
64
+ sbx.destroy()
65
+ ```
66
+
67
+ Docs: <https://sandbox.mosaicos.com/docs/>
@@ -0,0 +1,45 @@
1
+ # Mosaic Sandbox Python SDK
2
+
3
+ `mosaic-sandbox` is the Python SDK and CLI for Mosaic Sandbox, a Firecracker-based
4
+ runtime for coding agents.
5
+
6
+ Install:
7
+
8
+ ```bash
9
+ python -m pip install mosaic-sandbox
10
+ ```
11
+
12
+ Set your endpoint and API token:
13
+
14
+ ```bash
15
+ export MAR_ENDPOINT=https://sandbox.mosaicos.com
16
+ export MAR_API_TOKEN=msk_live_...
17
+ ```
18
+
19
+ Run the CLI smoke test:
20
+
21
+ ```bash
22
+ mar doctor
23
+ mar run --template node-20 node -v
24
+ ```
25
+
26
+ Python example:
27
+
28
+ ```python
29
+ from mosaic_sandbox import Sandbox
30
+
31
+ sbx = Sandbox.create(
32
+ template="node-20",
33
+ endpoint="https://sandbox.mosaicos.com",
34
+ api_token="msk_live_..."
35
+ )
36
+
37
+ try:
38
+ result = sbx.run_command("node -v")
39
+ print(result.stdout)
40
+ print(result.tti_ms)
41
+ finally:
42
+ sbx.destroy()
43
+ ```
44
+
45
+ Docs: <https://sandbox.mosaicos.com/docs/>
@@ -0,0 +1,6 @@
1
+ """Low-overhead client for the Mosaic Sandbox daemon."""
2
+ __version__ = "0.1.0"
3
+
4
+ from .client import AsyncProcessHandle, AsyncSandbox, ExecutionResult, FileInfo, Preview, ProcessHandle, ProcessInfo, Sandbox, Snapshot
5
+
6
+ __all__ = ["AsyncProcessHandle", "AsyncSandbox", "ExecutionResult", "FileInfo", "Preview", "ProcessHandle", "ProcessInfo", "Sandbox", "Snapshot", "__version__"]
@@ -0,0 +1,372 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import shlex
7
+ import sys
8
+ from dataclasses import asdict
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from . import __version__
13
+ from .client import ProcessHandle, ProcessInfo, Sandbox
14
+ from .migration import PROVIDER_INDEX, assess as assess_migration
15
+ from .migration import list_providers, plan as plan_migration
16
+
17
+
18
+ DEFAULT_ENDPOINT = os.environ.get("MAR_ENDPOINT", "http://127.0.0.1:8080")
19
+
20
+
21
+ def _request(args: argparse.Namespace, path: str, payload: dict[str, Any] | None = None, method: str = "GET") -> dict[str, Any]:
22
+ return Sandbox._request(
23
+ args.endpoint,
24
+ path,
25
+ payload or {},
26
+ method=method,
27
+ api_token=args.token,
28
+ )
29
+
30
+
31
+ def _emit(value: Any, *, raw: bool = False) -> None:
32
+ if raw and isinstance(value, str):
33
+ sys.stdout.write(value)
34
+ if value and not value.endswith("\n"):
35
+ sys.stdout.write("\n")
36
+ return
37
+ print(json.dumps(value, indent=2, sort_keys=True))
38
+
39
+
40
+ def _sandbox(args: argparse.Namespace) -> Sandbox:
41
+ return Sandbox(args.id, args.endpoint, args.token)
42
+
43
+
44
+ def _command(parts: list[str]) -> str:
45
+ if parts[:1] == ["--"]:
46
+ parts = parts[1:]
47
+ if not parts:
48
+ raise ValueError("a command is required")
49
+ return shlex.join(parts)
50
+
51
+
52
+ def _load_manifest(path: str) -> dict[str, Any]:
53
+ raw = sys.stdin.read() if path == "-" else Path(path).read_text()
54
+ value = json.loads(raw)
55
+ if not isinstance(value, dict):
56
+ raise ValueError("migration manifest must be a JSON object")
57
+ return value
58
+
59
+
60
+ def _ssh_key(args: argparse.Namespace) -> str | None:
61
+ path = getattr(args, "ssh_public_key_file", None)
62
+ return Path(path).expanduser().read_text().strip() if path else None
63
+
64
+
65
+ def _sandbox_created(sandbox: Sandbox) -> dict[str, Any]:
66
+ return {
67
+ "id": sandbox.id,
68
+ "state": "running",
69
+ "tti_ms": sandbox._create_tti_ms,
70
+ "ssh_connection_string": sandbox.ssh_connection_string,
71
+ "ssh_host": sandbox.ssh_host,
72
+ "ssh_port": sandbox.ssh_port,
73
+ "ssh_private_key_path": sandbox.ssh_private_key_path,
74
+ "ssh_known_hosts_path": sandbox.ssh_known_hosts_path,
75
+ }
76
+
77
+
78
+ def _agent_config(client: str, endpoint: str) -> str:
79
+ if client == "codex":
80
+ return (
81
+ "[mcp_servers.mosaic_sandbox]\n"
82
+ 'command = "mar"\n'
83
+ 'args = ["mcp"]\n\n'
84
+ "[mcp_servers.mosaic_sandbox.env]\n"
85
+ f'MAR_ENDPOINT = "{endpoint}"\n'
86
+ )
87
+ config = {
88
+ "mcpServers": {
89
+ "mosaic-sandbox": {
90
+ "command": "mar",
91
+ "args": ["mcp"],
92
+ "env": {"MAR_ENDPOINT": endpoint},
93
+ }
94
+ }
95
+ }
96
+ if client == "cursor":
97
+ return json.dumps(config, indent=2) + "\n"
98
+ payload = json.dumps(
99
+ {
100
+ "type": "stdio",
101
+ "command": "mar",
102
+ "args": ["mcp"],
103
+ "env": {"MAR_ENDPOINT": endpoint},
104
+ },
105
+ separators=(",", ":"),
106
+ )
107
+ return (
108
+ "claude mcp add-json mosaic-sandbox "
109
+ + shlex.quote(payload)
110
+ + " --scope project\n"
111
+ )
112
+
113
+
114
+ def build_parser() -> argparse.ArgumentParser:
115
+ parser = argparse.ArgumentParser(
116
+ prog="mar",
117
+ description="Mosaic Sandbox CLI and MCP server for coding agents.",
118
+ )
119
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
120
+ parser.add_argument("--endpoint", default=DEFAULT_ENDPOINT, help="MAR REST endpoint")
121
+ parser.add_argument("--token", default=os.environ.get("MAR_API_TOKEN"), help="Bearer token; prefer MAR_API_TOKEN")
122
+ sub = parser.add_subparsers(dest="command", required=True)
123
+
124
+ create = sub.add_parser("create", help="Create a sandbox")
125
+ create.add_argument("--template", default="node-20")
126
+ create.add_argument("--memory-mb", type=int, default=4096)
127
+ create.add_argument("--vcpu", type=int, default=2)
128
+ create.add_argument("--no-ssh", action="store_true", help="Disable the default SSH endpoint")
129
+ create.add_argument("--ssh-public-key-file", help="Public key to install instead of generating a temporary keypair")
130
+ create.add_argument("--ssh-private-key", help="Local private key matching --ssh-public-key-file")
131
+
132
+ execute = sub.add_parser("exec", help="Execute a command in an existing sandbox")
133
+ execute.add_argument("--cwd")
134
+ execute.add_argument("--env", action="append", default=[], metavar="NAME=VALUE")
135
+ execute.add_argument("--stdin")
136
+ execute.add_argument("id")
137
+ execute.add_argument("cmd", nargs=argparse.REMAINDER)
138
+ execute.add_argument("--timeout-ms", type=int)
139
+
140
+ run = sub.add_parser("run", help="Create, execute, and destroy a sandbox")
141
+ run.add_argument("--template", default="node-20")
142
+ run.add_argument("--memory-mb", type=int, default=4096)
143
+ run.add_argument("--vcpu", type=int, default=2)
144
+ run.add_argument("--timeout-ms", type=int)
145
+ run.add_argument("--cwd")
146
+ run.add_argument("--env", action="append", default=[], metavar="NAME=VALUE")
147
+ run.add_argument("--stdin")
148
+ run.add_argument("--keep", action="store_true", help="Keep the sandbox after execution")
149
+ run.add_argument("cmd", nargs=argparse.REMAINDER)
150
+
151
+ for name in ("info", "pause", "resume", "destroy"):
152
+ lifecycle = sub.add_parser(name, help=f"{name.capitalize()} a sandbox")
153
+ lifecycle.add_argument("id")
154
+
155
+ sub.add_parser("health", help="Check daemon health")
156
+ sub.add_parser("metrics", help="Read runtime metrics")
157
+ sub.add_parser("mcp", help="Run the MCP stdio server")
158
+
159
+ files = sub.add_parser("files", help="Read and modify /workspace")
160
+ file_sub = files.add_subparsers(dest="file_command", required=True)
161
+ file_list = file_sub.add_parser("list", help="List a directory")
162
+ file_list.add_argument("id"); file_list.add_argument("path", nargs="?", default="/workspace")
163
+ file_read = file_sub.add_parser("read", help="Read a file")
164
+ file_read.add_argument("id"); file_read.add_argument("path"); file_read.add_argument("--raw", action="store_true")
165
+ file_write = file_sub.add_parser("write", help="Write a local file or stdin")
166
+ file_write.add_argument("id"); file_write.add_argument("path"); file_write.add_argument("--from", dest="source", default="-")
167
+ file_mkdir = file_sub.add_parser("mkdir", help="Create a directory")
168
+ file_mkdir.add_argument("id"); file_mkdir.add_argument("path"); file_mkdir.add_argument("--no-recursive", action="store_true")
169
+ file_delete = file_sub.add_parser("delete", help="Delete a file or directory")
170
+ file_delete.add_argument("id"); file_delete.add_argument("path"); file_delete.add_argument("--recursive", action="store_true")
171
+ file_move = file_sub.add_parser("move", help="Move a file or directory")
172
+ file_move.add_argument("id"); file_move.add_argument("source"); file_move.add_argument("destination"); file_move.add_argument("--overwrite", action="store_true")
173
+
174
+ process = sub.add_parser("process", help="Manage durable processes")
175
+ process_sub = process.add_subparsers(dest="process_command", required=True)
176
+ process_start = process_sub.add_parser("start"); process_start.add_argument("id"); process_start.add_argument("cmd", nargs=argparse.REMAINDER); process_start.add_argument("--cwd")
177
+ for name in ("status", "logs", "kill"):
178
+ operation = process_sub.add_parser(name); operation.add_argument("id"); operation.add_argument("process_id")
179
+ process_input = process_sub.add_parser("input"); process_input.add_argument("id"); process_input.add_argument("process_id"); process_input.add_argument("data")
180
+
181
+ snapshot = sub.add_parser("snapshot", help="Manage checkpoints and forks")
182
+ snapshot_sub = snapshot.add_subparsers(dest="snapshot_command", required=True)
183
+ snapshot_create = snapshot_sub.add_parser("create"); snapshot_create.add_argument("id"); snapshot_create.add_argument("--name"); snapshot_create.add_argument("--retention-seconds", type=int)
184
+ snapshot_sub.add_parser("list")
185
+ for name in ("info", "delete"):
186
+ operation = snapshot_sub.add_parser(name); operation.add_argument("snapshot_id")
187
+ snapshot_fork = snapshot_sub.add_parser("fork"); snapshot_fork.add_argument("snapshot_id")
188
+
189
+ preview = sub.add_parser("preview", help="Manage expiring preview URLs")
190
+ preview_sub = preview.add_subparsers(dest="preview_command", required=True)
191
+ preview_create = preview_sub.add_parser("create"); preview_create.add_argument("id"); preview_create.add_argument("port", type=int); preview_create.add_argument("--expires", type=int, default=3600)
192
+ preview_revoke = preview_sub.add_parser("revoke"); preview_revoke.add_argument("id"); preview_revoke.add_argument("preview_id")
193
+ preview_ready = preview_sub.add_parser("ready"); preview_ready.add_argument("id"); preview_ready.add_argument("preview_id")
194
+
195
+ config = sub.add_parser("agent-config", help="Print MCP setup for a coding agent")
196
+ config.add_argument("client", choices=("codex", "claude", "cursor"))
197
+
198
+ migrate = sub.add_parser("migrate", help="Assess and plan migrations to Mosaic")
199
+ migrate_sub = migrate.add_subparsers(dest="migration_command", required=True)
200
+ migrate_sub.add_parser("providers", help="List supported source providers")
201
+ for name in ("assess", "plan"):
202
+ operation = migrate_sub.add_parser(name, help=f"{name.capitalize()} a migration")
203
+ operation.add_argument("--provider", required=True, choices=tuple(PROVIDER_INDEX))
204
+ operation.add_argument("--manifest", required=True, help="JSON manifest path, or - for stdin")
205
+
206
+ sub.add_parser("doctor", help="Check CLI configuration and daemon connectivity")
207
+ return parser
208
+
209
+
210
+ def _parse_env(values: list[str]) -> dict[str, str]:
211
+ result = {}
212
+ for value in values:
213
+ if "=" not in value or value.startswith("="):
214
+ raise ValueError(f"invalid environment assignment: {value}")
215
+ key, item = value.split("=", 1)
216
+ result[key] = item
217
+ return result
218
+
219
+
220
+ def main(argv: list[str] | None = None) -> int:
221
+ args = build_parser().parse_args(argv)
222
+ try:
223
+ if args.command == "mcp":
224
+ os.environ["MAR_ENDPOINT"] = args.endpoint
225
+ if args.token:
226
+ os.environ["MAR_API_TOKEN"] = args.token
227
+ from .mcp_server import main as mcp_main
228
+
229
+ mcp_main()
230
+ return 0
231
+ if args.command == "agent-config":
232
+ _emit(_agent_config(args.client, args.endpoint), raw=True)
233
+ return 0
234
+ if args.command == "migrate":
235
+ if args.migration_command == "providers":
236
+ _emit({"providers": list_providers()})
237
+ else:
238
+ manifest = _load_manifest(args.manifest)
239
+ result = (
240
+ assess_migration(args.provider, manifest)
241
+ if args.migration_command == "assess"
242
+ else plan_migration(args.provider, manifest)
243
+ )
244
+ _emit(result)
245
+ return 0
246
+ if args.command == "files":
247
+ sandbox = Sandbox(args.id, args.endpoint, args.token)
248
+ if args.file_command == "list":
249
+ _emit({"path": args.path, "entries": [asdict(entry) for entry in sandbox.files.list(args.path)]})
250
+ elif args.file_command == "read":
251
+ content = sandbox.files.read_bytes(args.path)
252
+ if args.raw:
253
+ sys.stdout.buffer.write(content)
254
+ else:
255
+ _emit({"path": args.path, "content": content.decode("utf-8"), "size": len(content)})
256
+ elif args.file_command == "write":
257
+ content = sys.stdin.buffer.read() if args.source == "-" else Path(args.source).read_bytes()
258
+ _emit({"path": args.path, "size": sandbox.files.write(args.path, content)})
259
+ elif args.file_command == "mkdir":
260
+ sandbox.files.mkdir(args.path, recursive=not args.no_recursive); _emit({"path": args.path, "created": True})
261
+ elif args.file_command == "delete":
262
+ sandbox.files.remove(args.path, recursive=args.recursive); _emit({"path": args.path, "deleted": True})
263
+ elif args.file_command == "move":
264
+ sandbox.files.move(args.source, args.destination, overwrite=args.overwrite); _emit({"source": args.source, "destination": args.destination, "moved": True})
265
+ return 0
266
+ if args.command == "process":
267
+ sandbox = Sandbox(args.id, args.endpoint, args.token)
268
+ if args.process_command == "start": _emit(asdict(sandbox.process.start(args.cmd[1:] if args.cmd[:1] == ["--"] else args.cmd, cwd=args.cwd).initial))
269
+ elif args.process_command == "status": _emit(asdict(ProcessHandle(sandbox, ProcessInfo(**sandbox._call("GET", f"/processes/{args.process_id}"))).info()))
270
+ elif args.process_command == "logs": _emit(sandbox._call("GET", f"/processes/{args.process_id}/logs"))
271
+ elif args.process_command == "input": sandbox._call("POST", f"/processes/{args.process_id}/input", {"data": args.data}); _emit({"sent": True})
272
+ elif args.process_command == "kill": sandbox._call("DELETE", f"/processes/{args.process_id}"); _emit({"killed": True})
273
+ return 0
274
+ if args.command == "snapshot":
275
+ if args.snapshot_command == "list": _emit(_request(args, "/v1/snapshots"))
276
+ elif args.snapshot_command == "info": _emit(_request(args, f"/v1/snapshots/{args.snapshot_id}"))
277
+ elif args.snapshot_command == "delete": _emit(_request(args, f"/v1/snapshots/{args.snapshot_id}", method="DELETE"))
278
+ elif args.snapshot_command == "fork":
279
+ sandbox = Sandbox.create(endpoint=args.endpoint, api_token=args.token, snapshot_id=args.snapshot_id); _emit(_sandbox_created(sandbox))
280
+ else: _emit(asdict(Sandbox(args.id, args.endpoint, args.token).snapshot(args.name, args.retention_seconds)))
281
+ return 0
282
+ if args.command == "preview":
283
+ sandbox = Sandbox(args.id, args.endpoint, args.token)
284
+ if args.preview_command == "create": _emit(asdict(sandbox.preview(args.port, args.expires)))
285
+ elif args.preview_command == "ready": _emit({"preview_id": args.preview_id, "ready": sandbox.preview_ready(args.preview_id)})
286
+ else: sandbox.revoke_preview(args.preview_id); _emit({"revoked": True})
287
+ return 0
288
+ if args.command == "health":
289
+ _emit(_request(args, "/healthz"))
290
+ return 0
291
+ if args.command == "metrics":
292
+ _emit(_request(args, "/metrics"))
293
+ return 0
294
+ if args.command == "doctor":
295
+ health = _request(args, "/healthz")
296
+ _emit(
297
+ {
298
+ "cli": "ok",
299
+ "endpoint": args.endpoint,
300
+ "authentication": "configured" if args.token else "not configured",
301
+ "daemon": health,
302
+ }
303
+ )
304
+ return 0
305
+ if args.command == "create":
306
+ sandbox = Sandbox.create(
307
+ template=args.template,
308
+ memory_mb=args.memory_mb,
309
+ vcpu=args.vcpu,
310
+ endpoint=args.endpoint,
311
+ api_token=args.token,
312
+ enable_ssh=not args.no_ssh,
313
+ ssh_public_key=_ssh_key(args),
314
+ ssh_private_key_path=args.ssh_private_key,
315
+ )
316
+ _emit(_sandbox_created(sandbox))
317
+ return 0
318
+ if args.command == "exec":
319
+ if not args.cmd:
320
+ raise ValueError("exec requires a command after the sandbox ID")
321
+ result = _sandbox(args).run_command(
322
+ _command(args.cmd),
323
+ args.timeout_ms,
324
+ cwd=args.cwd,
325
+ env=_parse_env(args.env),
326
+ stdin=args.stdin,
327
+ )
328
+ _emit(asdict(result))
329
+ return result.exit_code
330
+ if args.command == "run":
331
+ if not args.cmd:
332
+ raise ValueError("run requires a command")
333
+ sandbox = Sandbox.create(
334
+ template=args.template,
335
+ memory_mb=args.memory_mb,
336
+ vcpu=args.vcpu,
337
+ endpoint=args.endpoint,
338
+ api_token=args.token,
339
+ enable_ssh=False,
340
+ )
341
+ try:
342
+ result = sandbox.run_command(
343
+ _command(args.cmd),
344
+ args.timeout_ms,
345
+ cwd=args.cwd,
346
+ env=_parse_env(args.env),
347
+ stdin=args.stdin,
348
+ )
349
+ _emit({"sandbox_id": sandbox.id, **asdict(result)})
350
+ return result.exit_code
351
+ finally:
352
+ if not args.keep:
353
+ sandbox.destroy()
354
+ if args.command == "info":
355
+ _emit(_request(args, f"/v1/sandboxes/{args.id}"))
356
+ return 0
357
+ sandbox = _sandbox(args)
358
+ if args.command == "pause":
359
+ sandbox.pause()
360
+ elif args.command == "resume":
361
+ sandbox.resume()
362
+ elif args.command == "destroy":
363
+ sandbox.destroy()
364
+ _emit({"id": args.id, "state": args.command})
365
+ return 0
366
+ except (RuntimeError, ValueError) as error:
367
+ print(f"mar: {error}", file=sys.stderr)
368
+ return 1
369
+
370
+
371
+ if __name__ == "__main__":
372
+ raise SystemExit(main())