Skip to content

Commit 25f5aba

Browse files
committed
Add example for running Cursor Agent self-hosted workers in Modal Sandboxes
1 parent 6dd28f4 commit 25f5aba

File tree

1 file changed

+262
-0
lines changed

1 file changed

+262
-0
lines changed
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
# ---
2+
# cmd: ["python", "13_sandboxes/cursor_self_hosted_worker.py"]
3+
# pytest: false
4+
# ---
5+
6+
# # Run Cursor self-hosted cloud agent workers in Modal Sandboxes
7+
8+
# This example demonstrates how to run [Cursor self-hosted cloud agent
9+
# workers](https://cursor.com/docs/cloud-agent/self-hosted) inside Modal
10+
# [Sandboxes](https://modal.com/docs/guide/sandboxes).
11+
#
12+
# In Cursor's architecture, each agent session gets its own dedicated
13+
# worker process, started with `agent worker start`. Each worker connects
14+
# outbound over HTTPS, so they do not need inbound ports, firewall
15+
# changes, or VPN tunnels.
16+
17+
import argparse
18+
19+
import modal
20+
21+
MINUTES = 60
22+
DEFAULT_TIMEOUT_HOURS = 12
23+
DEFAULT_GITHUB_REPO = "modal-labs/modal-examples"
24+
25+
26+
# ## Build an Image with Cursor Agent installed
27+
28+
# We install the official Cursor Agent CLI using Cursor's install script.
29+
# The installer creates `~/.local/bin/agent` so we add that directory to
30+
# `PATH`.
31+
32+
image = (
33+
modal.Image.debian_slim(python_version="3.12")
34+
.apt_install("curl", "git")
35+
.env({"PATH": "/root/.local/bin:${PATH}"})
36+
.run_commands("curl -fsSL https://cursor.com/install | bash")
37+
)
38+
39+
40+
# ## Grant Cursor and GitHub access
41+
42+
# Cursor authentication should come from a Modal
43+
# [Secret](https://modal.com/docs/guide/secrets) containing `CURSOR_API_KEY`.
44+
# Create one in the Modal Dashboard and use `cursor-worker-secret` as the
45+
# default name, or pass `--cursor-secret` to use a different Secret name.
46+
#
47+
# For private GitHub repositories, pass a personal access token with
48+
# `--github-token`. The script converts that token into an ephemeral Modal
49+
# Secret for the Sandboxes it launches.
50+
#
51+
# Each worker serves one repository checkout, specified using
52+
# `--github-repo` in `owner/repo` format. The repository is cloned into
53+
# `/code/<owner>/<repo>`.
54+
55+
56+
# ## Start a worker inside a Sandbox
57+
58+
# The worker itself is just a process, started with `agent worker start`.
59+
# If we provide a GitHub token, we only use it for the one repository this
60+
# worker serves. We clone via an authenticated HTTPS URL and update that
61+
# checkout's `origin` remote.
62+
63+
WORKER_START_SCRIPT = r"""
64+
set -euo pipefail
65+
66+
repo="${1}"
67+
repo_dir="${2}"
68+
clone_url="https://github.com/${repo}.git"
69+
70+
if [ -n "${GH_TOKEN:-}" ]; then
71+
clone_url="https://oauth2:${GH_TOKEN}@github.com/${repo}.git"
72+
export GIT_TERMINAL_PROMPT=0
73+
fi
74+
75+
mkdir -p "$(dirname "${repo_dir}")"
76+
git clone --quiet --depth 1 "${clone_url}" "${repo_dir}"
77+
if [ -n "${GH_TOKEN:-}" ]; then
78+
git -C "${repo_dir}" remote set-url origin "${clone_url}"
79+
fi
80+
cd "${repo_dir}"
81+
82+
exec /root/.local/bin/agent worker start
83+
""".strip()
84+
85+
86+
def create_worker_sandbox(
87+
*,
88+
app: modal.App,
89+
timeout: int,
90+
cursor_secret_name: str,
91+
github_repo: str,
92+
github_repo_dir: str,
93+
github_token: str | None,
94+
) -> modal.Sandbox:
95+
cursor_secret = modal.Secret.from_name(
96+
cursor_secret_name,
97+
required_keys=["CURSOR_API_KEY"],
98+
)
99+
100+
secrets = [cursor_secret]
101+
if github_token:
102+
secrets.append(modal.Secret.from_dict({"GH_TOKEN": github_token}))
103+
104+
worker_start_cmd = [
105+
"bash",
106+
"-lc",
107+
WORKER_START_SCRIPT,
108+
"worker-start",
109+
github_repo,
110+
github_repo_dir,
111+
]
112+
113+
return modal.Sandbox.create(
114+
*worker_start_cmd,
115+
app=app,
116+
image=image,
117+
secrets=secrets,
118+
timeout=timeout,
119+
)
120+
121+
122+
# ## Putting it all together
123+
124+
125+
def main(
126+
timeout: int,
127+
app_name: str,
128+
cursor_secret_name: str,
129+
github_token: str | None,
130+
github_repo: str,
131+
num_workers: int,
132+
):
133+
app = modal.App.lookup(app_name, create_if_missing=True)
134+
github_repo_dir = github_repo_to_workdir(github_repo)
135+
136+
print(f"\nStarting {num_workers} Cursor Agent workers for {github_repo}...\n")
137+
with modal.enable_output():
138+
for i in range(num_workers):
139+
sandbox = create_worker_sandbox(
140+
app=app,
141+
timeout=timeout,
142+
cursor_secret_name=cursor_secret_name,
143+
github_token=github_token,
144+
github_repo=github_repo,
145+
github_repo_dir=github_repo_dir,
146+
)
147+
print(f"Worker {i}: sandbox_id={sandbox.object_id}")
148+
149+
print(
150+
"\nWorkers launched. Enable self-hosted cloud agents in your Cursor "
151+
"Dashboard, then start an agent session to route work to these workers."
152+
)
153+
154+
print(f"\nModal dashboard: {app.get_dashboard_url()}")
155+
print("Cursor dashboard: https://cursor.com/dashboard/cloud-agents")
156+
157+
158+
# ## Command-line options
159+
160+
# This script supports configuration via command-line arguments.
161+
# Run with `--help` to see all options.
162+
163+
# To grant the agent the same GitHub permissions you have, you can pass a GitHub personal access token.
164+
# If you use the `gh` CLI, you can use shell command substitution to pass your current auth:
165+
166+
# ```bash
167+
# python 13_sandboxes/cursor_self_hosted_worker.py --github-token $(gh auth token) ...
168+
# ```
169+
170+
171+
def parse_timeout(timeout_str: str) -> int:
172+
if timeout_str.endswith("h"):
173+
minutes = int(timeout_str[:-1]) * 60
174+
elif timeout_str.endswith("m"):
175+
minutes = int(timeout_str[:-1])
176+
else:
177+
minutes = int(timeout_str) * 60
178+
179+
if minutes < 1:
180+
raise argparse.ArgumentTypeError("Timeout must be at least 1 minute")
181+
if minutes > 24 * 60:
182+
raise argparse.ArgumentTypeError("Timeout cannot exceed 24 hours")
183+
184+
return minutes * MINUTES
185+
186+
187+
def parse_num_workers(value: str) -> int:
188+
num_workers = int(value)
189+
if num_workers < 1:
190+
raise argparse.ArgumentTypeError("--num-workers must be at least 1")
191+
return num_workers
192+
193+
194+
def github_repo_to_workdir(github_repo: str) -> str:
195+
parts = [part for part in github_repo.split("/") if part]
196+
if len(parts) == 2:
197+
owner, repo = parts
198+
repo = repo.removesuffix(".git")
199+
if owner and repo:
200+
return f"/code/{owner}/{repo}"
201+
202+
raise argparse.ArgumentTypeError("--github-repo must be in owner/repo format")
203+
204+
205+
if __name__ == "__main__":
206+
parser = argparse.ArgumentParser(
207+
description="Launch Cursor self-hosted workers in Modal Sandboxes"
208+
)
209+
parser.add_argument(
210+
"--timeout",
211+
type=str,
212+
default=str(DEFAULT_TIMEOUT_HOURS),
213+
help=(
214+
"Worker timeout (e.g. 2h, 90m). "
215+
f"No suffix -> hours. Default: {DEFAULT_TIMEOUT_HOURS}"
216+
),
217+
)
218+
parser.add_argument(
219+
"--app-name",
220+
type=str,
221+
default="example-cursor-self-hosted-worker",
222+
help="Modal app name. Default: example-cursor-self-hosted-worker",
223+
)
224+
parser.add_argument(
225+
"--cursor-secret",
226+
dest="cursor_secret_name",
227+
type=str,
228+
default="cursor-worker-secret",
229+
help="Modal Secret containing CURSOR_API_KEY. Default: cursor-worker-secret",
230+
)
231+
parser.add_argument(
232+
"--github-token",
233+
type=str,
234+
default=None,
235+
help="GitHub PAT for private repositories. Tip: use $(gh auth token)",
236+
)
237+
parser.add_argument(
238+
"--github-repo",
239+
type=str,
240+
default=DEFAULT_GITHUB_REPO,
241+
help=(
242+
"GitHub repository to clone before starting the worker, "
243+
f"in owner/repo format. Default: {DEFAULT_GITHUB_REPO}"
244+
),
245+
)
246+
parser.add_argument(
247+
"--num-workers",
248+
type=parse_num_workers,
249+
default=1,
250+
help="Number of worker Sandboxes to launch. Default: 1",
251+
)
252+
253+
args = parser.parse_args()
254+
255+
main(
256+
parse_timeout(args.timeout),
257+
args.app_name,
258+
args.cursor_secret_name,
259+
args.github_token,
260+
args.github_repo,
261+
args.num_workers,
262+
)

0 commit comments

Comments
 (0)