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

LimitValue
Payload≤ 64 KiB (oversized ⇒ rejected at enqueue)
Stored result≤ 64 KiB (oversized ⇒ truncated before persisting)
Attemptsclamped to 1 … 5
Single-execution wall clockclamped 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 as failed instead of looping forever.
  • Failure reasons are the fixed codes worker_failed and worker_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 sandbox uid/gid (10001), a cross-uid kernel boundary the child cannot cross to read /proc/<orchestrator>/environ or ptrace the orchestrator;
  • uses shell: false argv — 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:

ControlDefault
mem_limit2 g (CODE_WORKER_MEM_LIMIT)
cpus1.0 (CODE_WORKER_CPUS)
pids_limit512 (CODE_WORKER_PIDS_LIMIT)
Capabilitiescap_drop: ALL, then only SETUID, SETGID, CHOWN
Filesystemread_only: true, writes confined to the code-workspace volume and a 256 MiB /tmp tmpfs
Privilege escalationno-new-privileges: true; the default Docker seccomp profile stays applied
NetworkThe 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
Do not put a plugin job on the default network

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 idCommand
selftestA 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-testThe 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.

Protocol only: no enqueue adapter

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.