Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
8 changes: 8 additions & 0 deletions cli/src/secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,14 @@ export const FIRST_PARTY_SECRET_SPECS: readonly SecretSpec[] = [
required: { when: { kind: "sandbox-backend", backend: "e2b" } },
description: "E2B API key used by the e2b sandbox backend (from e2b.dev dashboard).",
},
{
name: "DO_AGENTS_API_TOKEN",
service: "core",
required: { when: { kind: "sandbox-backend", backend: "do-managed-agents" } },
description:
"DigitalOcean IAM token for the DigitalOcean Managed Agents sandbox backend. Carries the team identity that owns the sandbox sessions, and authenticates both the session REST calls and the port-forward tunnel that carries exec and file transfer.",
generate: "doctl auth init # then create a personal access token at cloud.digitalocean.com/account/api/tokens",
},
{
name: "MODAL_TOKEN_ID",
service: "core",
Expand Down
13 changes: 13 additions & 0 deletions deploy/do-managed-agents/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
FROM node:24-slim@sha256:6f7b03f7c2c8e2e784dcf9295400527b9b1270fd37b7e9a7285cf83b6951452d

ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates git curl wget jq tar xz-utils unzip zip ripgrep \
python3 python3-pip python3-venv \
openssh-client gnupg less vim-tiny procps \
&& rm -rf /var/lib/apt/lists/* \
&& node --version && npm --version

ENV HOME=/workspace/home
ENTRYPOINT ["sleep", "infinity"]
CMD []
51 changes: 51 additions & 0 deletions deploy/do-managed-agents/register-template.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Build the QM sandbox image and register it as a Managed Agents template
# on your own DigitalOcean team. Run once per team, and again after the
# Dockerfile changes.
#
# Needs: docker (buildx), doctl authenticated with a token that has
# registry and Managed Agents access, and an existing container registry.
#
# Usage: deploy/do-managed-agents/register-template.sh [tag]
# DO_REGISTRY registry name (default: first registry on the account)
# TEMPLATE_NAME template name (default: qm-sandbox)
set -euo pipefail

TAG="${1:-$(date +%Y%m%d%H%M%S)}"
TEMPLATE_NAME="${TEMPLATE_NAME:-qm-sandbox}"
DIR="$(cd "$(dirname "$0")" && pwd)"

for bin in docker doctl jq; do
command -v "$bin" >/dev/null || { echo "missing: $bin" >&2; exit 1; }
done

DO_REGISTRY="${DO_REGISTRY:-$(doctl registry get -o json | jq -r 'if type=="array" then .[0].name else .name end // empty')}"
[ -n "$DO_REGISTRY" ] || { echo "no container registry found; create one or set DO_REGISTRY" >&2; exit 1; }
IMAGE="registry.digitalocean.com/$DO_REGISTRY/$TEMPLATE_NAME:$TAG"

echo "building $IMAGE"
docker build --platform linux/amd64 -t "$IMAGE" "$DIR"
doctl registry login >/dev/null
docker push "$IMAGE"

if doctl harness-runtime template get "$TEMPLATE_NAME" >/dev/null 2>&1; then
echo "updating template $TEMPLATE_NAME"
doctl harness-runtime template update "$TEMPLATE_NAME" --source-oci-ref "$IMAGE" >/dev/null
else
echo "creating template $TEMPLATE_NAME"
doctl harness-runtime template create --name "$TEMPLATE_NAME" \
--base-template sandbox --source-oci-ref "$IMAGE" >/dev/null
fi

echo "waiting for the template build"
for _ in $(seq 1 60); do
status="$(doctl harness-runtime template list-builds "$TEMPLATE_NAME" -o json \
| jq -r 'sort_by(.created_at) | last | .status')"
case "$status" in
SUCCEEDED) echo "ready. Set DO_AGENTS_TEMPLATE=$TEMPLATE_NAME in your QM config."; exit 0 ;;
FAILED) echo "template build failed" >&2; exit 1 ;;
esac
sleep 20
done
echo "timed out waiting for the template build (last status: $status)" >&2
exit 1
39 changes: 39 additions & 0 deletions docs/do-managed-agents.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# DigitalOcean Managed Agents sandboxes

The `do-managed-agents` backend runs each QM scope on a DigitalOcean Managed Agents microVM. QM keeps its own agent loop; sessions start with `agent: none`, so DigitalOcean supplies the machine and nothing else.

## Set it up

1. Create a DigitalOcean API token with Managed Agents access.
2. Point core at it:

```sh
SANDBOX_BACKEND=do-managed-agents
DO_AGENTS_API_TOKEN=your-token
```

That's enough to run. Sessions land on DigitalOcean's bare base image.

## Optional

**A QM template.** For an image with the tools agents usually reach for (Node, git, jq, ripgrep, Python), register one on your team. You need Docker, an authenticated `doctl`, and a container registry:

```sh
deploy/do-managed-agents/register-template.sh
DO_AGENTS_TEMPLATE=qm-sandbox
```

Templates belong to a team, so each team registers its own. Rerun the script after changing the Dockerfile.

**A snapshot bucket.** When a scope parks, QM takes a DigitalOcean checkpoint, which lets it roll a wedged computer back in place. A checkpoint is tied to its session, though, so if the session is deleted the checkpoint goes with it. Set `DO_AGENTS_SNAPSHOT_S3_BUCKET` to also keep a copy of the home directory that survives that. Without it, the copy lives in core's memory and is lost when core restarts.

**An egress proxy.** Set `DO_AGENTS_EGRESS_PROXY_URL` and the proxy host becomes the session's only allowed destination.

Other knobs: `DO_AGENTS_SIZE_SLUG`, `DO_AGENTS_IDLE_TIMEOUT_SEC`, `DO_AGENTS_NAME_PREFIX` (use a distinct one per deployment sharing a team), `DO_AGENTS_SNAPSHOT_INTERVAL_SEC`, and `DO_AGENTS_API_BASE_URL`.

## Good to know

- Commands and file transfers go over a port-forward to the microVM rather than DigitalOcean's REST exec, which caps output and runtime. Long commands are bounded only by `SANDBOX_TIMEOUT_SEC`.
- QM doesn't reap idle machines itself. DigitalOcean's idle timeout does, and paused sessions still count against team quota.
- Sessions allow `bash` outright, since QM already runs its own approval gates.
- `scripts/managed-agents-sandbox-smoke.ts` checks a token and template end to end before you point a deployment at them.
2 changes: 2 additions & 0 deletions docs/sandbox-preservation.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,5 @@ Modal machines are created with a one-core, 2 GiB reservation (`MODAL_CPUS`, `MO
When `MODAL_EGRESS_PROXY_URL` is set, machines are created with Modal's outbound allowlist limited to the proxy host (a domain allowlist for an https proxy on port 443, a CIDR allowlist for an IP address; other proxy URLs are rejected at startup because Modal domain allowlists pass only TLS on 443), and the profile reports `egressEnforcement: "domain"`. Command execution, file transfer, and checkpoints travel over Modal's control plane and are unaffected. Without the setting, core warns that Modal sandboxes run fail-open.

Turn environment reaches Modal commands through the exec `env` parameter rather than inlined `export` statements, exec deadlines are rounded up to whole seconds with a single thirty-second grace beyond the in-guest `timeout`, and a command longer than 64 KiB is written to `/tmp` through the filesystem API and executed from there.

Managed Agents sessions name a sandbox template explicitly, which yields a microVM carrying no managed agent loop, and the disk persists across the provider's pause and resume. Used-turn teardown calls `POST /v2/agents/sessions/{id}/checkpoints` when the home changed, throttled by `DO_AGENTS_SNAPSHOT_INTERVAL_SEC` from the last successful capture, then snapshots the home as a tar on that same interval measured from the last successful tar. The tar goes to `DO_AGENTS_SNAPSHOT_S3_BUCKET` when it is set; without it the tar is held in core's memory and does not survive a core restart. A failed tar is retried without taking another checkpoint. The checkpoint is the provider's machine-state capture of the live session (`CreateCheckpoint` blocks until the capture is `READY`); only the newest is kept, and the previous one is deleted after the new capture succeeds. A delete that fails stays recorded and is retried on a later teardown. A capture failure is recorded on `recovery.error` and the pause still proceeds. A pause failure is recorded as `pause_failed` and the running session is retained for retry. Status reports `recovery.strategy: "workspace_snapshot"` plus `recovery.checkpointId`. Restart of a wedged computer calls `POST /v2/agents/sessions/{id}/checkpoints/{checkpoint_id}/rollback` with that stored checkpoint: the session id stays, the response's sandbox id replaces the stored one, and any open port-forward is closed so the next command dials the restored microVM. A paused session is rolled back directly, without being resumed first. Restart with no checkpoint fails and leaves the session where it is. A checkpoint is bound to its session, so a replacement session for a destroyed or reclaimed scope still hydrates from the portable tar when one exists, and a failed hydration kills the replacement rather than adopting a blank home over a saved one. Session lifecycle travels over the Managed Agents REST surface with a DigitalOcean IAM token; commands and file transfer travel over a bearer-authenticated WebSocket port-forward to the guest's `sandbox-agent` gRPC server, which has no request deadline, so command length is bounded by `SANDBOX_TIMEOUT_SEC` rather than by the control plane. Sessions are created with a permissions block that allows `bash` outright, because Managed Agents would otherwise hold every command at its own approval gate behind core's.
4 changes: 4 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@
"smoke:service-cred": "node scripts/service-credential-livetest.ts",
"smoke:aws-sandbox": "node scripts/aws-sandbox-smoke.ts",
"smoke:local-sandbox": "node scripts/local-sandbox-smoke.ts",
"smoke:managed-agents-sandbox": "node --env-file-if-exists=.env scripts/managed-agents-sandbox-smoke.ts",
"smoke:managed-agents-turn": "node --env-file-if-exists=.env scripts/managed-agents-turn-smoke.ts",
"build:modal-image": "node scripts/modal-build-image.ts",
"build:e2b-template": "node scripts/e2b-build-template.ts",
"build:aws-image": "node scripts/aws-build-sandbox-image.ts",
Expand Down Expand Up @@ -77,6 +79,8 @@
"@earendil-works/pi-ai": "0.82.0",
"@earendil-works/pi-coding-agent": "https://github.com/yc-software/qm/releases/download/vendored-pi-coding-agent-0.82.0-security.5/earendil-works-pi-coding-agent-0.82.0-qm-security.5.tgz",
"@fly/sprites": "0.2.3",
"@grpc/grpc-js": "^1.14.5",
"@grpc/proto-loader": "^0.8.1",
"@openai/codex": "0.156.1",
"@opencode-ai/plugin": "1.18.31",
"@opencode-ai/sdk": "1.18.31",
Expand All @@ -101,6 +105,7 @@
"socks-proxy-agent": "10.1.0",
"tar-stream": "^3.2.0",
"typebox": "^1.1.38",
"ws": "^8.21.1",
"zod": "4.4.3"
},
"overrides": {
Expand All @@ -126,6 +131,7 @@
"@types/node": "^24.0.0",
"@types/pg": "^8.11.10",
"@types/tar-stream": "^3.1.4",
"@types/ws": "^8.18.1",
"@yc-software/qm": "file:./cli",
"devbar.sh": "1.1.0",
"esbuild": "0.28.1",
Expand Down
2 changes: 1 addition & 1 deletion scripts/dev/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -671,7 +671,7 @@ async function main(): Promise<number> {
});
default:
console.error(
"usage: dev [up|down|status|restart|canary|logs|doctor] [--json] [--force] [--rotate] [--strict] [--sandbox local|sprites|smolmachines|e2b|porter|agent37|superserve|auto] [--surface web|slack|both] [--no-slack] [--no-watch] [--org id] [--fix]",
"usage: dev [up|down|status|restart|canary|logs|doctor] [--json] [--force] [--rotate] [--strict] [--sandbox local|sprites|smolmachines|e2b|porter|agent37|superserve|do-managed-agents|auto] [--surface web|slack|both] [--no-slack] [--no-watch] [--org id] [--fix]",
);
return EXIT.usage;
}
Expand Down
38 changes: 38 additions & 0 deletions scripts/dev/lib/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,44 @@ export async function resolveSandbox(opts: {
return { backend: "superserve", env, detail: `Superserve (template ${template})`, publicApiUrl: apiUrl, warnings };
}

if (backend === "do-managed-agents") {
const managedAgentsToken = opts.baseEnv.DO_AGENTS_API_TOKEN?.trim();
if (!managedAgentsToken)
throw new Error(
"--sandbox do-managed-agents requires DO_AGENTS_API_TOKEN in the environment (a DigitalOcean API token for a team with Managed Agents access)",
);
let managedAgentsApiUrl = opts.baseEnv.PUBLIC_API_URL || null;
if (!managedAgentsApiUrl) {
managedAgentsApiUrl = await startQuickTunnel(opts.corePort, opts.lock, opts.log);
if (!managedAgentsApiUrl)
warnings.push(
"cloudflared tunnel didn't come up -- agent self-API (crons/sends) won't be reachable from the sandbox",
);
}
const env: Record<string, string> = {
SANDBOX_BACKEND: "do-managed-agents",
DO_AGENTS_API_TOKEN: managedAgentsToken,
DO_AGENTS_NAME_PREFIX: opts.baseEnv.DO_AGENTS_NAME_PREFIX || "qmdev",
};
if (opts.baseEnv.DO_AGENTS_API_BASE_URL) env.DO_AGENTS_API_BASE_URL = opts.baseEnv.DO_AGENTS_API_BASE_URL;
if (opts.baseEnv.DO_AGENTS_TEMPLATE) env.DO_AGENTS_TEMPLATE = opts.baseEnv.DO_AGENTS_TEMPLATE;
if (opts.baseEnv.DO_AGENTS_SIZE_SLUG) env.DO_AGENTS_SIZE_SLUG = opts.baseEnv.DO_AGENTS_SIZE_SLUG;
if (opts.baseEnv.DO_AGENTS_EGRESS_PROXY_URL)
env.DO_AGENTS_EGRESS_PROXY_URL = opts.baseEnv.DO_AGENTS_EGRESS_PROXY_URL;
else
warnings.push(
"DO_AGENTS_EGRESS_PROXY_URL unset -- do-managed-agents sandbox runs with NO egress enforcement; set it to QA the forced-proxy path",
);
if (managedAgentsApiUrl) env.PUBLIC_API_URL = managedAgentsApiUrl;
return {
backend: "do-managed-agents",
env,
detail: `Managed Agents (${opts.baseEnv.DO_AGENTS_API_BASE_URL || "api.digitalocean.com"})`,
publicApiUrl: managedAgentsApiUrl,
warnings,
};
}

if (backend === "porter") {
const porterToken = opts.baseEnv.PORTER_DEPLOY_API_TOKEN;
const projectId = opts.baseEnv.PORTER_DEPLOY_PROJECT_ID;
Expand Down
3 changes: 2 additions & 1 deletion scripts/dev/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,8 @@ export interface StatusReport {
children: Record<string, ChildStatus & { slack?: SlackHealth }>;
}

export type DevSandboxBackend = "local" | "sprites" | "smolmachines" | "e2b" | "porter" | "agent37" | "superserve";
export type DevSandboxBackend =
"local" | "sprites" | "smolmachines" | "e2b" | "porter" | "agent37" | "superserve" | "do-managed-agents";
export type DevSandboxChoice = DevSandboxBackend | "auto";

export interface BootSpec {
Expand Down
Loading
Loading