Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,7 @@
.env.*
*.pem
*.key
api_key
aws
.DS_Store
app.zip
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ jobs:
grep -q "AWS::Events::Rule" cloudformation.yaml
grep -q "ReservedConcurrentExecutions: 1" cloudformation.yaml
grep -q "rate(1 minute)" cloudformation.yaml
grep -q "CONTROLLER_RUN_SECONDS: \"300\"" cloudformation.yaml
grep -q "CONTROLLER_POOL_NAMES" cloudformation.yaml
grep -q "CONTROLLER_REPOSITORY_URLS" cloudformation.yaml
grep -q "CONTROLLER_ALL_POOLS" cloudformation.yaml
grep -q "agent worker controller --spawn" controller/handler.py
grep -q "aws lambda-microvms run-microvm" spawn.sh
grep -q -- "--run-hook-payload" spawn.sh
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,7 @@ dist/
.env.*
*.pem
*.key
api_key
aws
.DS_Store
app.zip
19 changes: 11 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ A controller launches one MicroVM per pending pool request:

1. You start a cloud agent at [cursor.com/agents](https://cursor.com/agents) against a self-hosted pool. The request stays pending until a worker claims it.
2. The controller (`agent worker controller --spawn ./spawn.sh`) sees that request and runs [`spawn.sh`](spawn.sh).
3. `spawn.sh` calls [`aws lambda-microvms run-microvm`](https://docs.aws.amazon.com/cli/latest/reference/lambda-microvms/run-microvm.html) (`RunMicrovm`) and returns. `--run-hook-payload` forwards `CURSOR_*` into the guest.
4. The MicroVM `/run` hook ([`hook.py`](microvm-image/hook.py)) applies that payload and starts [`entrypoint.sh`](microvm-image/entrypoint.sh), which runs `cursor-agent worker start --pool --worker-dir`. The worker executes tool calls in your account.
3. `spawn.sh` calls [`aws lambda-microvms run-microvm`](https://docs.aws.amazon.com/cli/latest/reference/lambda-microvms/run-microvm.html) (`RunMicrovm`) and returns. `--run-hook-payload` forwards claim `CURSOR_*` into the guest (not `CURSOR_API_ENDPOINT` / `CURSOR_API_URL`; those point at the public REST host and make `worker start` treat the service-account key as invalid).
4. The MicroVM `/run` hook ([`hook.py`](microvm-image/hook.py)) applies that payload and starts [`entrypoint.sh`](microvm-image/entrypoint.sh), which runs `cursor-agent worker --pool --worker-dir start`. The worker executes tool calls in your account.
5. When the session is idle, the worker releases and the MicroVM can terminate.

## Key properties
Expand All @@ -28,7 +28,7 @@ A controller launches one MicroVM per pending pool request:

Product semantics: [Self-hosted pools](https://cursor.com/docs/cloud-agent/self-hosted-guides/pool.md) ([repo-less / Any repo](https://cursor.com/docs/cloud-agent/self-hosted-guides/pool.md#repo-less-pools), [pool names](https://cursor.com/docs/cloud-agent/self-hosted-guides/pool.md#pool-names), [multiple repo roots](https://cursor.com/docs/cloud-agent/self-hosted-guides/pool.md#register-multiple-repo-roots)). `repo` and `pool` labels are reserved; the worker derives `repo=` from a git remote when one exists.

**This template’s default:** the controller Lambda runs `agent worker controller --spawn ./spawn.sh --pool default` (`PoolName` / `POOL_NAME` / `CURSOR_POOL` in [`cloudformation.yaml`](cloudformation.yaml)). The guest image is built with `POOL_NAME=default`. [`hook.py`](microvm-image/hook.py) does not clone. [`entrypoint.sh`](microvm-image/entrypoint.sh) starts the worker from `/opt/cursor/workspaces/workspace` (a `git init` with **no remote** unless `CURSOR_REPO_URL` / `REPO_URL` is set). Keep stack `PoolName` and image `POOL_NAME` the same.
**This template’s default:** the controller Lambda runs `agent worker controller --spawn ./spawn.sh --pool default` (`PoolNames` / `POOL_NAMES` in [`cloudformation.yaml`](cloudformation.yaml) and [`deploy.sh`](deploy.sh)). The guest image is built with `POOL_NAME=default`. [`hook.py`](microvm-image/hook.py) does not clone. [`entrypoint.sh`](microvm-image/entrypoint.sh) starts the worker from `/opt/cursor/workspaces/workspace` (a `git init` with **no remote** unless `CURSOR_REPO_URL` / `REPO_URL` is set). Keep stack `PoolNames` and image `POOL_NAME` the same. Pass `ALL_POOLS=true` or `REPOSITORY_URLS=...` instead of (or with) `POOL_NAMES` when you need `--all-pools` or `--repository`.

### Any-repo mode

Expand Down Expand Up @@ -82,7 +82,10 @@ Users pick the repo in the dashboard (the pool appears under that repo). The poo
2. Deploy the stack. `./deploy.sh` builds the controller image and runs `aws cloudformation deploy` on [`cloudformation.yaml`](cloudformation.yaml):

```bash
./deploy.sh
POOL_NAMES=default ./deploy.sh
# POOL_NAMES=gpu,default ./deploy.sh
# REPOSITORY_URLS=https://github.com/org/repo ./deploy.sh
# ALL_POOLS=true ./deploy.sh
```

3. Build the worker image from [`microvm-image/`](microvm-image/) (needs the stack outputs):
Expand Down Expand Up @@ -110,7 +113,7 @@ Users pick the repo in the dashboard (the pool appears under that repo). The poo

Open [cursor.com/agents](https://cursor.com/agents). Choose **Self-hosted**.

- **Any-repo mode:** pick the **Any repo** group and the pool name (`default` unless you overrode `PoolName`).
- **Any-repo mode:** pick the **Any repo** group and the pool name (`default` unless you overrode `PoolNames`).
- **Repo-bound mode:** pick the repo. The pool appears under that repo.

## Alternative: run the controller locally
Expand All @@ -131,7 +134,7 @@ Assume stack output `SpawnRoleArn` so `spawn.sh` can call `run-microvm`. `MICROV

## Monitoring

Application logs go to CloudWatch under the MicroVM image name (`cursor-pool-worker`).
Guest logs go to CloudWatch log group `/aws/lambda/microvms/cursor-pool-worker`. Controller logs are `/aws/lambda/cursor-lambda-workers-controller`.

List running MicroVMs:

Expand All @@ -145,7 +148,7 @@ aws lambda-microvms list-microvms --image-identifier cursor-pool-worker
| --- | --- |
| Image build fails (S3 or IAM) | Confirm stack outputs `ArtifactBucketName` and `BuildRoleArn`. The zip must land in that bucket, and the build role must be able to read it. |
| No MicroVM | Confirm the controller is running and can call `run-microvm`. For a local controller, assume `SpawnRoleArn`. Confirm image `cursor-pool-worker` exists. |
| Worker dies immediately | The guest needs `CURSOR_API_KEY` (SSM `/cursor-lambda-workers/cursor-api-key`). Confirm the `/run` hook started `cursor-agent worker start --pool`. |
| Worker dies immediately | The guest needs `CURSOR_API_KEY` (SSM `/cursor-lambda-workers/cursor-api-key`). Confirm the `/run` hook started `cursor-agent worker --pool … start`. If logs show `Exec format error` on `node`, the image installed the wrong CLI arch (MicroVMs here are aarch64). If auth says the API key is invalid, do not set `CURSOR_API_ENDPOINT` to `https://api.cursor.com`. |
| CLI too old for `controller` | Pin a lab version in `microvm-image/cursor-agent-version` and rebuild the MicroVM image and the controller image. |

## Related resources
Expand All @@ -162,6 +165,6 @@ aws lambda-microvms list-microvms --image-identifier cursor-pool-worker

Optional. To test a CLI off `main`, put a lab version (`YYYY.MM.DD-<sha7>`) in [`microvm-image/cursor-agent-version`](microvm-image/cursor-agent-version) and rebuild the MicroVM image **and** the controller image.

Lab builds: [cursor.com/install?channel=lab](https://cursor.com/install?channel=lab), the `agent-cli.yml` GitHub Action on `main`, or `https://downloads.cursor.com/lab/<version>/linux/x64/agent-cli-package.tar.gz`.
Lab builds: [cursor.com/install?channel=lab](https://cursor.com/install?channel=lab), the `agent-cli.yml` GitHub Action on `main`, or `https://downloads.cursor.com/lab/<version>/linux/arm64/agent-cli-package.tar.gz` (x64 for amd64 guests).

Leave the file empty (comments only) to use the prod installer at [cursor.com/install](https://cursor.com/install).
36 changes: 28 additions & 8 deletions cloudformation.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,23 @@ Parameters:
ControllerImageUri:
Type: String
Description: ECR image URI for the controller Lambda (controller/Dockerfile). Build with ./deploy.sh.
PoolName:
PoolNames:
Type: String
Default: default
Description: Pool label workers register as (`agent worker controller --pool` / `worker start --pool`).
Default: ""
Description: >-
Comma-separated pool names for `agent worker controller --pool` (repeatable).
Mutually exclusive with AllPools=true. Set PoolNames, AllPools, and/or RepositoryUrls.
AllPools:
Type: String
AllowedValues: ["true", "false"]
Default: "false"
Description: If true, pass `--all-pools` (team-wide). Mutually exclusive with PoolNames.
RepositoryUrls:
Type: String
Default: ""
Description: >-
Comma-separated repository URLs for `agent worker controller --repository`.
Required for repo-scoped API keys. Can combine with PoolNames or AllPools.
MicroVmImageIdentifier:
Type: String
Default: cursor-pool-worker
Expand Down Expand Up @@ -139,7 +152,9 @@ Resources:
Statement:
- Effect: Allow
Action: ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"]
Resource: !Sub "arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/${ProjectName}-controller*"
Resource:
- !Sub "arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/${ProjectName}-controller*"
- !Sub "arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/microvms/*"
- Effect: Allow
Action: ssm:GetParameter
Resource: !Sub "arn:${AWS::Partition}:ssm:${AWS::Region}:${AWS::AccountId}:parameter${CursorApiKeyParamName}"
Expand Down Expand Up @@ -181,8 +196,9 @@ Resources:
ImageUri: !Ref ControllerImageUri
Environment:
Variables:
CURSOR_POOL: !Ref PoolName
POOL_NAME: !Ref PoolName
CONTROLLER_POOL_NAMES: !Ref PoolNames
CONTROLLER_ALL_POOLS: !Ref AllPools
CONTROLLER_REPOSITORY_URLS: !Ref RepositoryUrls
SPAWN_SCRIPT: /var/task/spawn.sh
CURSOR_AGENT_BIN: /usr/local/bin/agent
CONTROLLER_RUN_SECONDS: "300"
Expand Down Expand Up @@ -234,5 +250,9 @@ Outputs:
Value: !GetAtt MicroVmExecutionRole.Arn
SpawnRoleArn:
Value: !GetAtt SpawnRole.Arn
PoolName:
Value: !Ref PoolName
PoolNames:
Value: !Ref PoolNames
AllPools:
Value: !Ref AllPools
RepositoryUrls:
Value: !Ref RepositoryUrls
65 changes: 60 additions & 5 deletions controller/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,71 @@
SHUTDOWN_GRACE_SECONDS = 15


def split_names(value: str | None) -> list[str]:
parts: list[str] = []
for chunk in (value or "").replace(";", ",").split(","):
name = chunk.strip()
if name:
parts.append(name)
return parts


def truthy(value: str | None) -> bool:
return (value or "").strip().lower() in {"1", "true", "yes", "on"}


def extra_has_flag(extra: list[str], *names: str) -> bool:
flags = set(names)
prefixes = tuple(f"{name}=" for name in names)
return any(part in flags or part.startswith(prefixes) for part in extra)


def controller_args(env: dict[str, str]) -> list[str]:
script = env.get("SPAWN_SCRIPT") or os.path.join(os.getcwd(), "spawn.sh")
extra = [part for part in env.get("CURSOR_WORKER_CONTROLLER_ARGS", "").split() if part]
args = ["worker", "controller", "--spawn", script]
has_pool = any(
part == "--pool" or part == "--all-pools" or part.startswith("--pool=") for part in extra

all_pools = extra_has_flag(extra, "--all-pools") or truthy(
env.get("CONTROLLER_ALL_POOLS") or env.get("ALL_POOLS")
)
pools = split_names(
env.get("CONTROLLER_POOL_NAMES")
or env.get("POOL_NAMES")
or env.get("POOL_NAME")
or env.get("CURSOR_POOL")
)
repos = split_names(
env.get("CONTROLLER_REPOSITORY_URLS")
or env.get("REPOSITORY_URLS")
or env.get("CURSOR_REPO_URL")
)
if not has_pool:
pool = env.get("POOL_NAME") or env.get("CURSOR_POOL") or "default"
args.extend(["--pool", pool])

if extra_has_flag(extra, "--pool", "--all-pools"):
pass
elif all_pools:
args.append("--all-pools")
elif pools:
for pool in pools:
args.extend(["--pool", pool])
elif repos:
# CLI requires --pool or --all-pools; repo filters apply on top.
args.append("--all-pools")

if not extra_has_flag(extra, "--repository"):
for repo in repos:
args.extend(["--repository", repo])

if (
not extra_has_flag(extra, "--pool", "--all-pools", "--repository")
and not all_pools
and not pools
and not repos
):
raise RuntimeError(
"configure CONTROLLER_POOL_NAMES, CONTROLLER_ALL_POOLS=true, "
"and/or CONTROLLER_REPOSITORY_URLS"
)

args.extend(extra)
return args

Expand Down
25 changes: 22 additions & 3 deletions deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,22 @@ ACCOUNT="$(aws sts get-caller-identity --query Account --output text "${REGION_A
REPO="${CONTROLLER_ECR_REPO:-cursor-lambda-workers-controller}"
TAG="${CONTROLLER_IMAGE_TAG:-$(git rev-parse --short HEAD 2>/dev/null || echo local)-$(date +%Y%m%d%H%M%S)}"
URI="${ACCOUNT}.dkr.ecr.${REGION}.amazonaws.com/${REPO}:${TAG}"
POOL_NAME="${POOL_NAME:-default}"
POOL_NAMES="${POOL_NAMES:-${POOL_NAME:-}}"
ALL_POOLS="${ALL_POOLS:-false}"
case "${ALL_POOLS}" in
1|true|TRUE|yes|YES|on|ON) ALL_POOLS=true ;;
*) ALL_POOLS=false ;;
esac
REPOSITORY_URLS="${REPOSITORY_URLS:-}"
CURSOR_API_KEY_PARAM_NAME="${CURSOR_API_KEY_PARAM_NAME:-/cursor-lambda-workers/cursor-api-key}"
MICROVM_IMAGE_IDENTIFIER="${MICROVM_IMAGE_IDENTIFIER:-cursor-pool-worker}"

if [[ -z "${POOL_NAMES}" && "${ALL_POOLS}" != "true" && -z "${REPOSITORY_URLS}" ]]; then
echo "Set POOL_NAMES (comma-separated), ALL_POOLS=true, and/or REPOSITORY_URLS so the controller knows what to serve." >&2
echo "Example: POOL_NAMES=default ./deploy.sh" >&2
exit 1
fi

if ! command -v docker >/dev/null 2>&1; then
echo "docker is required to build the controller Lambda image." >&2
exit 1
Expand Down Expand Up @@ -70,7 +82,9 @@ aws cloudformation deploy \
--capabilities CAPABILITY_NAMED_IAM \
--parameter-overrides \
"ControllerImageUri=${URI}" \
"PoolName=${POOL_NAME}" \
"PoolNames=${POOL_NAMES}" \
"AllPools=${ALL_POOLS}" \
"RepositoryUrls=${REPOSITORY_URLS}" \
"CursorApiKeyParamName=${CURSOR_API_KEY_PARAM_NAME}" \
"MicroVmImageIdentifier=${MICROVM_IMAGE_IDENTIFIER}" \
"${REGION_ARG[@]}"
Expand All @@ -84,4 +98,9 @@ aws lambda invoke \
"${REGION_ARG[@]}" >/dev/null

echo "Stack ${STACK_NAME} deployed. Controller ${FUNC} is running (5-minute SSE window; EventBridge rate(1 minute) restarts it)."
echo "Build the MicroVM image next, then start an agent from cursor.com/agents against pool ${POOL_NAME}."
if [[ "${ALL_POOLS}" == "true" ]]; then
echo "Serving all pools${REPOSITORY_URLS:+, repositories ${REPOSITORY_URLS}}."
else
echo "Serving pools ${POOL_NAMES:-none}${REPOSITORY_URLS:+, repositories ${REPOSITORY_URLS}}."
fi
echo "Build the MicroVM image next (POOL_NAME should match a pool this controller serves), then start an agent from cursor.com/agents."
9 changes: 7 additions & 2 deletions microvm-image/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@ RUN dnf install -y --setopt=install_weak_deps=0 \
# set the version in cursor-agent-version instead (ships in the zip).
COPY cursor-agent-version /tmp/cursor-agent-version
ARG CURSOR_AGENT_VERSION=
ARG CURSOR_AGENT_ARCH=x64
# Guest MicroVMs here are aarch64. The previous x64 default installed a node
# binary that failed at runtime with "cannot execute binary file".
ARG CURSOR_AGENT_ARCH=arm64
RUN VERSION="$(sed -e 's/#.*//' -e 's/[[:space:]]//g' /tmp/cursor-agent-version | sed -e '/^$/d' | head -n 1)" && \
VERSION="${VERSION:-${CURSOR_AGENT_VERSION:-}}" && \
ARCH="${CURSOR_AGENT_ARCH:-x64}" && \
ARCH="${CURSOR_AGENT_ARCH:-arm64}" && \
echo "installing cursor-agent ${VERSION:-prod} linux/${ARCH} (build uname=$(uname -m))" && \
if [ -n "${VERSION}" ]; then \
mkdir -p "/root/.local/share/cursor-agent/versions/${VERSION}" /root/.local/bin && \
curl -fsSL "https://downloads.cursor.com/lab/${VERSION}/linux/${ARCH}/agent-cli-package.tar.gz" \
Expand All @@ -29,6 +32,8 @@ RUN VERSION="$(sed -e 's/#.*//' -e 's/[[:space:]]//g' /tmp/cursor-agent-version
rm -f /tmp/cursor-agent-version

ENV PATH="/root/.cursor/bin:/root/.local/bin:/usr/local/bin:${PATH}"
ENV HOME=/root
ENV NODE_COMPILE_CACHE=/tmp/cursor-compile-cache
WORKDIR /opt/cursor
COPY entrypoint.sh hook.py /opt/cursor/
RUN chmod +x /opt/cursor/entrypoint.sh /opt/cursor/hook.py && mkdir -p /opt/cursor/workspaces
Expand Down
2 changes: 1 addition & 1 deletion microvm-image/cursor-agent-version
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@
# This file ships in the create-microvm-image zip (AWS may not pass Docker --build-arg).
# Put the version on its own line, rebuild the MicroVM image and the controller image.
# Prod CLI may omit `worker controller`; pin a lab version if `controller` is missing.
2026.08.20-f582eb6
2026.08.21-4bf0f61
18 changes: 16 additions & 2 deletions microvm-image/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
# Clone if needed, then exec cursor-agent worker start --pool.
set -euo pipefail
export GIT_TERMINAL_PROMPT=0
export HOME="${HOME:-/root}"
export NODE_COMPILE_CACHE="${NODE_COMPILE_CACHE:-/tmp/cursor-compile-cache}"
export PATH="/root/.cursor/bin:/root/.local/bin:/usr/local/bin:${PATH}"

if [[ -z "${CURSOR_API_KEY:-}" && -n "${CURSOR_API_KEY_PARAM_NAME:-}" ]]; then
Expand All @@ -12,6 +14,11 @@ if [[ -z "${CURSOR_API_KEY:-}" ]]; then
echo "FATAL: set CURSOR_API_KEY or CURSOR_API_KEY_PARAM_NAME" >&2
exit 1
fi
# Controller Lambda sets these to https://api.cursor.com (public REST).
# `worker start` uses --endpoint for /auth/exchange_user_api_key, which lives
# on the default CLI host (api2.cursor.sh). Forwarding api.cursor.com makes
# every service-account key look invalid.
unset CURSOR_API_ENDPOINT CURSOR_API_URL

POOL_NAME="${POOL_NAME:-${CURSOR_POOL:-default}}"
IDLE_RELEASE_TIMEOUT_SECONDS="${IDLE_RELEASE_TIMEOUT_SECONDS:-300}"
Expand All @@ -37,9 +44,16 @@ fi

cd "${dest}"
AGENT_BIN="$(command -v cursor-agent || command -v agent)"
echo "entrypoint: pool=${POOL_NAME} dest=${dest} worker_id=${CURSOR_AGENT_WORKER_ID:-} agent=${AGENT_BIN} uname=$(uname -m)" >&2
NAME_ARGS=()
if [[ -n "${CURSOR_WORKER_NAME:-}" ]]; then
NAME_ARGS=(--name "${CURSOR_WORKER_NAME}")
fi
exec "${AGENT_BIN}" worker start --pool "${POOL_NAME}" --worker-dir "${dest}" \
--idle-release-timeout "${IDLE_RELEASE_TIMEOUT_SECONDS}" "${NAME_ARGS[@]}"
WORKER_ID_ARGS=()
if [[ -n "${CURSOR_AGENT_WORKER_ID:-}" ]]; then
WORKER_ID_ARGS=(--worker-id "${CURSOR_AGENT_WORKER_ID}")
fi
# Flags belong on `worker` (parent). `start` only accepts --verbose.
exec "${AGENT_BIN}" worker --pool "${POOL_NAME}" --worker-dir "${dest}" \
--idle-release-timeout "${IDLE_RELEASE_TIMEOUT_SECONDS}" \
"${WORKER_ID_ARGS[@]}" "${NAME_ARGS[@]}" start --verbose
4 changes: 4 additions & 0 deletions microvm-image/hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ def do_POST(self):
env = json.loads(payload) if isinstance(payload, str) else payload
if isinstance(env, dict):
os.environ.update({str(k): str(v) for k, v in env.items() if v is not None})
os.environ.setdefault("HOME", "/root")
os.environ.setdefault("NODE_COMPILE_CACHE", "/tmp/cursor-compile-cache")
keys = sorted(k for k in os.environ if k.startswith("CURSOR_"))
print(f"hook /run microvmId={body.get('microvmId')} cursor_keys={keys}", flush=True)
subprocess.Popen(
["/opt/cursor/entrypoint.sh"],
env=os.environ.copy(),
Expand Down
Loading
Loading