Sandboxed Plugin Jobs
Tier 3: untrusted or heavy plugin compute behind an OS process boundary with no ambient credentials.
Tier 3 specifies compute that should not run inside Convex: untrusted input parsing, heavy analysis, anything that could hang or blow up. The worker-side polling, lease and outcome protocol remains implemented, but no Convex enqueue adapter or operator job UI is shipped, so plugins cannot start jobs today.
The same container actively serves the coding-agent queue. Its dormant plugin poller uses the same hardened executor rather than creating a parallel security boundary.
What a plugin can and cannot do
worker:enqueue is reserved. A future host adapter may grant enqueue only; claiming, cancelling, reclaiming, and reading must remain host and operator operations.
A job kind is plugin.<pluginId>.<localId>. The host checks kind ownership from the string itself, so a plugin attempting to enqueue plugin.other-plugin.job is denied. The plugin chooses which built-in job kind to run; the command is host-controlled and is never built from the payload. The untrusted payload is always passed as a discrete argv element, never interpolated into a shell string.
Enqueue limits
| Limit | Value |
|---|---|
| Payload | ≤ 64 KiB (oversized ⇒ rejected at enqueue) |
| Stored result | ≤ 64 KiB (oversized ⇒ truncated before persisting) |
| Attempts | clamped to 1 … 5 |
| Single-execution wall clock | clamped to 1 s … 15 min |
| In-flight jobs per (organization, plugin) | 100 queued + running |
A future enqueue adapter must fail closed for a disabled, ungranted, or undeclared plugin, a cross-plugin job kind, an oversized payload, or an exhausted in-flight budget. Terminal jobs do not count against the in-flight cap.
Job lifecycle
queued ──claim──▶ running ──complete──▶ succeeded
│ │
│ ├──fail (attempts < max)──▶ queued
│ ├──fail (attempts = max)──▶ failed
│ └──cancel requested──────▶ cancelled
└──cancel requested, then claim───────────▶ cancelled (never runs)
- Heartbeats prove liveness and are how a worker learns of an operator cancel. A cancelled running job's next heartbeat tells the worker to kill the whole process group.
- Cancellation cannot be escaped and a cancelled job is never retried.
- Lease reclaim returns jobs abandoned by a crashed or restarted worker. The lease window is generously beyond the maximum execution budget, so a slow-but-alive job that keeps heartbeating is never reclaimed out from under itself; each sweep is bounded.
- Retries are bounded by the host-clamped
maxAttempts, so a poison job terminates asfailedinstead of looping forever. - Failure reasons are the fixed codes
worker_failedandworker_timeout; any error message is clamped.
Enqueue and every terminal outcome write a pluginId-attributed audit row.
The sandbox
The worker orchestrator holds the deployment admin key (and, for the coding-agent queue, GITHUB_TOKEN and the LLM key) and runs as confined root purely so it can drop privileges. Every untrusted child is spawned through one seam that always:
- drops to the unprivileged
sandboxuid/gid (10001), a cross-uid kernel boundary the child cannot cross to read/proc/<orchestrator>/environor ptrace the orchestrator; - uses
shell: falseargv — no shell interpolation; - runs
detached, so the child leads its own process group and a timeout or cancel reaps the whole group, not just the direct child; - runs with every ambient credential stripped from the job environment. A plugin job never sees the admin key,
GITHUB_TOKEN, or the LLM key. Any credentialed capability is mediated by the host over Convex, never by leaking env into the sandbox; - is killed at the host-clamped wall-clock deadline (fail-closed).
Container-level ceilings, from docker-compose.yml:
| Control | Default |
|---|---|
mem_limit | 2 g (CODE_WORKER_MEM_LIMIT) |
cpus | 1.0 (CODE_WORKER_CPUS) |
pids_limit | 512 (CODE_WORKER_PIDS_LIMIT) |
| Capabilities | cap_drop: ALL, then only SETUID, SETGID, CHOWN |
| Filesystem | read_only: true, writes confined to the code-workspace volume and a 256 MiB /tmp tmpfs |
| Privilege escalation | no-new-privileges: true; the default Docker seccomp profile stays applied |
| Network | The isolated code-worker bridge, shared only with Convex — no route to the MTA admin port, IMAP, Redis, mail-sync, clamd, the updater, or the docker socket proxy |
The worker executes attacker-influenced code. Its network isolation is what cuts lateral movement into the mail and infrastructure tier. Adding it to the shared bridge would undo the whole boundary.
Built-in job kinds
The job-kind registry lives in the worker and is host-controlled, keyed by the local id:
| Local id | Command |
|---|---|
selftest | A no-op that exits 0 — proves the uid drop, env stripping, timeout, and cancellation wiring end to end without any plugin code in the image |
seed-test | The Deliverability Lab seed-list placement test; the compiled shim is the command and the untrusted payload arrives as a discrete argv element |
A new Tier-3 job kind is a change to the worker image, reviewed like any other host code — not something a plugin package can introduce on its own.
Operating the worker
COMPOSE_PROFILES=plugin-tasks docker compose up -d code-worker
The worker can authenticate to Convex with the deployment admin key and poll the dormant queue protocol, but current host code creates no rows. There is no recent-jobs query or cancellation UI.
Enabling the plugin-tasks profile starts a poller, but no shipped host path can enqueue work. Declaring worker:enqueue has no runtime effect until an adapter lands with a concrete producer and operator surface.