|
| 1 | +import time |
| 2 | +import json |
| 3 | +import hashlib |
| 4 | +import subprocess |
| 5 | + |
| 6 | +from typing import TYPE_CHECKING |
| 7 | +from .auth import create_jwt |
| 8 | + |
| 9 | +# Only import type hints for Modal if type checking |
| 10 | +if TYPE_CHECKING: |
| 11 | + import modal |
| 12 | + |
| 13 | +def create_named_modal_secret( |
| 14 | + env: dict[str, str | None], |
| 15 | + secret_name: str, |
| 16 | +): |
| 17 | + """ |
| 18 | + modal secret create [OPTIONS] SECRET_NAME KEYVALUES... |
| 19 | +
|
| 20 | + SECRET_NAME: [required] |
| 21 | + KEYVALUES...: Space-separated KEY=VALUE items [required] |
| 22 | +
|
| 23 | + 1) filter out None values |
| 24 | + 2) stable JSON stringify |
| 25 | + 3) take SHA256 hash of the string to get the SECRET_NAME |
| 26 | + 4) assemble KEYVALUES using shlex.quote |
| 27 | +
|
| 28 | + """ |
| 29 | + try: |
| 30 | + import modal |
| 31 | + except ImportError: |
| 32 | + raise ImportError( |
| 33 | + "Modal and PyJWT are required for this feature. " |
| 34 | + "Install them with: pip install 'lsproxy-sdk[modal]'" |
| 35 | + ) |
| 36 | + filtered_env = {k: v for k, v in env.items() if v is not None} |
| 37 | + |
| 38 | + # subprocess.run already handles quoting |
| 39 | + keyvalues = [f"{k}={v}" for k, v in filtered_env.items()] |
| 40 | + print(f"Creating Modal secret {secret_name} with {len(keyvalues)} keyvalues") |
| 41 | + result = subprocess.run( |
| 42 | + ["modal", "secret", "create", "--force", secret_name, *keyvalues], |
| 43 | + capture_output=True, |
| 44 | + text=True, |
| 45 | + ) |
| 46 | + if result.returncode != 0: |
| 47 | + raise Exception(f"Error creating Modal secret {secret_name}: {result.stderr}") |
| 48 | + return modal.Secret.from_name(secret_name) |
| 49 | + |
| 50 | +class ModalSandbox: |
| 51 | + def __init__(self, repo_url: str, git_token: str, sha: str, timeout: int, version: str): |
| 52 | + try: |
| 53 | + import modal |
| 54 | + import secrets |
| 55 | + except ImportError: |
| 56 | + raise ImportError( |
| 57 | + "Modal and PyJWT are required for this feature. " |
| 58 | + "Install them with: pip install 'lsproxy-sdk[modal]'" |
| 59 | + ) |
| 60 | + |
| 61 | + app = modal.App.lookup("lsproxy-app", create_if_missing=True) |
| 62 | + |
| 63 | + # Generate a secure random secret |
| 64 | + jwt_secret = secrets.token_urlsafe(32) |
| 65 | + |
| 66 | + # Create JWT token with 24-hour expiration |
| 67 | + payload = { |
| 68 | + "sub": "lsproxy-client", |
| 69 | + "iat": int(time.time()), |
| 70 | + "exp": int(time.time()) + 86400, # 24 hour expiration |
| 71 | + } |
| 72 | + self.jwt_token = create_jwt(payload, jwt_secret) |
| 73 | + |
| 74 | + |
| 75 | + if git_token: |
| 76 | + # We want the github secret to be named, because that allows us to cache the layers (for the same sha) even as the token changes! |
| 77 | + repo_id = hashlib.md5(repo_url.encode()).hexdigest() |
| 78 | + gh_secret = create_named_modal_secret( |
| 79 | + {"GITHUB_IAT": git_token}, |
| 80 | + f"gh-iat-token-{repo_id}" |
| 81 | + ) |
| 82 | + url_parts = repo_url.split("://") |
| 83 | + lsproxy_image = modal.Image.from_registry(f"agenticlabs/lsproxy:{version}") |
| 84 | + lsproxy_image = lsproxy_image.run_commands( |
| 85 | + [ |
| 86 | + "git config --global --add safe.directory /mnt/workspace", |
| 87 | + f"git clone --depth 1 {url_parts[0]}://x-access-token:$GITHUB_IAT@{url_parts[1]} /mnt/workspace" |
| 88 | + ], |
| 89 | + secrets=[ |
| 90 | + gh_secret |
| 91 | + ], # sneaky, cache the layers (for the same sha) even as the token changes! |
| 92 | + ) |
| 93 | + if sha: |
| 94 | + lsproxy_image = lsproxy_image.run_commands([ |
| 95 | + f"cd /mnt/workspace && git fetch origin {sha} && git checkout {sha}" |
| 96 | + ] |
| 97 | + ) |
| 98 | + else: |
| 99 | + lsproxy_image = modal.Image.from_registry(f"agenticlabs/lsproxy:{version}").run_commands( |
| 100 | + [ |
| 101 | + f"git clone --depth 1 {repo_url} /mnt/workspace" |
| 102 | + ] |
| 103 | + ) |
| 104 | + |
| 105 | + jwt_secret = modal.Secret.from_dict({"JWT_SECRET": jwt_secret}) |
| 106 | + sandbox_config = { |
| 107 | + "image": lsproxy_image, |
| 108 | + "app": app, |
| 109 | + "encrypted_ports": [4444], |
| 110 | + "secrets": [jwt_secret], |
| 111 | + } |
| 112 | + |
| 113 | + if timeout is not None: |
| 114 | + sandbox_config["timeout"] = timeout |
| 115 | + |
| 116 | + print("Starting sandbox...") |
| 117 | + |
| 118 | + self.sandbox = modal.Sandbox.create(**sandbox_config) |
| 119 | + self.tunnel_url = self.sandbox.tunnels()[4444].url |
| 120 | + |
| 121 | + # Start lsproxy |
| 122 | + p = self.sandbox.exec(f"lsproxy") |
| 123 | + |
| 124 | + def terminate(self): |
| 125 | + self.sandbox.terminate() |
0 commit comments