3 Commits

Author SHA1 Message Date
87c808ffa9 feat(api): add user metadata to capsules at create-time 2026-06-22 05:09:23 +06:00
61637694cb docs: rebrand copy to agent-native positioning
Update CLAUDE.md, README, and login tagline to 'runtime where AI
engineers live' positioning.
2026-06-22 04:54:59 +06:00
766bf7fccf docs(openapi): bump to 0.2.2 and align spec with handlers
- Version 0.2.0 -> 0.2.2 (matches VERSION_CP)
- Add missing response fields: MeResponse (user_id/team_id/role/is_admin),
  Team.is_byoc, TeamMember.name, APIKeyResponse.created_by/creator_email,
  Register/RefreshHostTokenResponse cert/key/ca PEM
- Fix audit-logs response shape (items + next_before/next_before_id)
- Fix wrong success codes: admin capsule create/destroy -> 202, build create -> 201
- Add reusable ServiceUnavailable (503); document 503/400/403/404/409 emitted
  across capsule, files, hosts, channels, teams, snapshots routes
- Fix HostHasCapsulesError example code, exec envs/cwd description
2026-06-22 04:54:59 +06:00
9 changed files with 489 additions and 49 deletions

View File

@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
Wrenn is an open-source, self-hosted dev environment platform. Users spin up isolated sandboxes (Cloud Hypervisor microVMs), run code inside them, and get output back via SDKs. Fast boot, persistent state, and a single agent binary on each host you own.
Wrenn is the runtime where AI engineers live — an open-source platform for running AI coding agents. Each project gets a persistent, isolated workspace (Cloud Hypervisor microVM) where agents edit code, run tests, debug, and ship continuously, with humans supervising via SDKs and chat surfaces. Fast boot, persistent state, available hosted or fully self-hosted with a single agent binary on each host you own. (The underlying primitive is still an isolated microVM — "sandbox" in the API/backend, "capsule" in the dashboard.)
## Build & Development Commands

View File

@ -1,8 +1,8 @@
# Wrenn
Secure infrastructure for AI
Runtime where AI engineers live
Wrenn is an open-source self-hosted dev environment platform. Each capsule is a fully isolated virtual machine — booted in seconds, persistent across sessions. Run the control plane anywhere, deploy a single agent binary on each compute host.
Wrenn is an open-source platform for running AI coding agents. Each project gets a persistent, isolated microVM workspace — booted in seconds, stateful across sessions — where agents edit code, run tests, debug, and ship continuously while humans supervise via SDKs and chat surfaces. Available hosted or fully self-hosted: run the control plane anywhere, deploy a single agent binary on each compute host you own.
## Prerequisites

View File

@ -205,7 +205,7 @@
class="relative z-10 mt-10 font-mono text-ui uppercase tracking-[0.1em] text-[var(--color-text-tertiary)]"
style="animation: fadeUp 0.35s ease 0.2s both"
>
Isolated VMs. Milliseconds to live.
Runtime where AI engineers live.
</p>
</div>

View File

@ -24,10 +24,11 @@ func newSandboxHandler(svc *service.SandboxService, al *audit.AuditLogger) *sand
}
type createSandboxRequest struct {
Template string `json:"template"`
VCPUs int32 `json:"vcpus"`
MemoryMB int32 `json:"memory_mb"`
TimeoutSec int32 `json:"timeout_sec"`
Template string `json:"template"`
VCPUs int32 `json:"vcpus"`
MemoryMB int32 `json:"memory_mb"`
TimeoutSec int32 `json:"timeout_sec"`
Metadata map[string]string `json:"metadata"`
}
type sandboxResponse struct {
@ -99,6 +100,7 @@ func (h *sandboxHandler) Create(w http.ResponseWriter, r *http.Request) {
VCPUs: req.VCPUs,
MemoryMB: req.MemoryMB,
TimeoutSec: req.TimeoutSec,
Metadata: req.Metadata,
})
h.audit.LogSandboxCreate(r.Context(), ac, sb.ID, req.Template, err)
if err != nil {

View File

@ -119,6 +119,8 @@ func serviceErrToHTTP(err error) (int, string, string) {
case strings.Contains(msg, "no online") && strings.Contains(msg, "hosts available"),
strings.Contains(msg, "no host has sufficient resources"):
return http.StatusServiceUnavailable, "no_hosts_available", "no servers available — try again later"
case strings.HasPrefix(msg, "invalid metadata: "):
return http.StatusBadRequest, "invalid_metadata", strings.TrimPrefix(msg, "invalid metadata: ")
case strings.Contains(msg, "invalid"):
return http.StatusBadRequest, "invalid_request", "invalid request"
default:

View File

@ -1,8 +1,8 @@
openapi: "3.1.0"
info:
title: Wrenn API
description: AI agent execution platform API.
version: "0.2.0"
description: API for Wrenn — the runtime where AI coding agents live, work, and ship.
version: "0.2.2"
servers:
- url: http://localhost:8080
@ -169,6 +169,8 @@ paths:
responses:
"204":
description: Session revoked
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
@ -202,6 +204,8 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/SessionResponse"
"400":
$ref: "#/components/responses/BadRequest"
"403":
description: Not a member of this team
content:
@ -239,6 +243,12 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Error"
"403":
description: Account not activated or disabled
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/auth/oauth/{provider}:
parameters:
@ -426,7 +436,7 @@ paths:
operationId: requestPasswordReset
tags: [account]
description: |
Sends a password reset link to the given email. Always returns 200
Sends a password reset link to the given email. Always returns 204
regardless of whether the email exists, to prevent account enumeration.
The reset token expires in 15 minutes.
requestBody:
@ -610,6 +620,8 @@ paths:
responses:
"204":
description: API key deleted
"400":
$ref: "#/components/responses/BadRequest"
/v1/users/search:
get:
@ -714,6 +726,8 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/TeamDetail"
"400":
$ref: "#/components/responses/BadRequest"
"403":
description: JWT team does not match requested team
content:
@ -772,6 +786,8 @@ paths:
responses:
"204":
description: Team deleted
"400":
$ref: "#/components/responses/BadRequest"
"403":
description: Caller is not the owner
content:
@ -802,6 +818,14 @@ paths:
type: array
items:
$ref: "#/components/schemas/TeamMember"
"400":
$ref: "#/components/responses/BadRequest"
"403":
description: JWT team does not match requested team
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
post:
summary: Add a member by email
@ -884,6 +908,12 @@ paths:
responses:
"204":
description: Role updated
"400":
description: Invalid team/user ID or invalid role
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"403":
description: Insufficient role or attempt to modify owner
content:
@ -907,6 +937,8 @@ paths:
responses:
"204":
description: Member removed
"400":
$ref: "#/components/responses/BadRequest"
"403":
description: Insufficient role or attempt to remove owner
content:
@ -938,6 +970,8 @@ paths:
responses:
"204":
description: Left the team
"400":
$ref: "#/components/responses/BadRequest"
"403":
description: Owner cannot leave
content:
@ -966,12 +1000,20 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Capsule"
"400":
$ref: "#/components/responses/BadRequest"
"403":
$ref: "#/components/responses/Forbidden"
"409":
$ref: "#/components/responses/FailedPrecondition"
"502":
description: Host agent error
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"503":
$ref: "#/components/responses/ServiceUnavailable"
get:
summary: List capsules for your team
@ -1072,6 +1114,8 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Capsule"
"400":
$ref: "#/components/responses/BadRequest"
"404":
description: Capsule not found
content:
@ -1089,6 +1133,14 @@ paths:
responses:
"202":
description: Capsule destruction initiated
"400":
$ref: "#/components/responses/BadRequest"
"404":
$ref: "#/components/responses/NotFound"
"409":
$ref: "#/components/responses/FailedPrecondition"
"503":
$ref: "#/components/responses/ServiceUnavailable"
/v1/capsules/{id}/exec:
parameters:
@ -1124,6 +1176,8 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/BackgroundExecResponse"
"400":
$ref: "#/components/responses/BadRequest"
"404":
description: Capsule not found
content:
@ -1136,6 +1190,8 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Error"
"503":
$ref: "#/components/responses/ServiceUnavailable"
/v1/capsules/{id}/processes:
parameters:
@ -1162,6 +1218,8 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ProcessListResponse"
"400":
$ref: "#/components/responses/BadRequest"
"404":
description: Capsule not found
content:
@ -1174,6 +1232,8 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Error"
"503":
$ref: "#/components/responses/ServiceUnavailable"
/v1/capsules/{id}/processes/{selector}:
parameters:
@ -1208,6 +1268,8 @@ paths:
responses:
"204":
description: Process killed
"400":
$ref: "#/components/responses/BadRequest"
"404":
description: Capsule or process not found
content:
@ -1220,6 +1282,8 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Error"
"503":
$ref: "#/components/responses/ServiceUnavailable"
/v1/capsules/{id}/processes/{selector}/stream:
parameters:
@ -1278,6 +1342,8 @@ paths:
responses:
"204":
description: Ping acknowledged, inactivity timer reset
"400":
$ref: "#/components/responses/BadRequest"
"404":
description: Capsule not found
content:
@ -1344,6 +1410,8 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Error"
"503":
$ref: "#/components/responses/ServiceUnavailable"
/v1/capsules/{id}/pause:
parameters:
@ -1371,12 +1439,18 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Capsule"
"400":
$ref: "#/components/responses/BadRequest"
"404":
$ref: "#/components/responses/NotFound"
"409":
description: Capsule not running
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"503":
$ref: "#/components/responses/ServiceUnavailable"
/v1/capsules/{id}/resume:
parameters:
@ -1405,12 +1479,18 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Capsule"
"400":
$ref: "#/components/responses/BadRequest"
"404":
$ref: "#/components/responses/NotFound"
"409":
description: Capsule not paused
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"503":
$ref: "#/components/responses/ServiceUnavailable"
/v1/snapshots:
post:
@ -1450,6 +1530,10 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Capsule"
"400":
$ref: "#/components/responses/BadRequest"
"404":
$ref: "#/components/responses/NotFound"
"409":
description: Name already exists, or capsule is not running or paused
content:
@ -1501,12 +1585,26 @@ paths:
responses:
"204":
description: Snapshot deleted
"400":
$ref: "#/components/responses/BadRequest"
"403":
description: Cannot delete a platform-owned / system base template
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"404":
description: Template not found
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"409":
description: Delete failed (e.g. owning host offline)
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/v1/capsules/{id}/files/write:
parameters:
@ -1541,6 +1639,10 @@ paths:
responses:
"204":
description: File uploaded
"400":
$ref: "#/components/responses/BadRequest"
"404":
$ref: "#/components/responses/NotFound"
"409":
description: Capsule not running
content:
@ -1553,6 +1655,8 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Error"
"503":
$ref: "#/components/responses/ServiceUnavailable"
/v1/capsules/{id}/files/read:
parameters:
@ -1583,12 +1687,22 @@ paths:
schema:
type: string
format: binary
"400":
$ref: "#/components/responses/BadRequest"
"404":
description: Capsule or file not found
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"409":
description: Capsule not running
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"503":
$ref: "#/components/responses/ServiceUnavailable"
/v1/capsules/{id}/files/list:
parameters:
@ -1618,6 +1732,8 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ListDirResponse"
"400":
$ref: "#/components/responses/BadRequest"
"404":
description: Capsule not found
content:
@ -1630,6 +1746,8 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Error"
"503":
$ref: "#/components/responses/ServiceUnavailable"
/v1/capsules/{id}/files/mkdir:
parameters:
@ -1659,6 +1777,8 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/MakeDirResponse"
"400":
$ref: "#/components/responses/BadRequest"
"404":
description: Capsule not found
content:
@ -1673,6 +1793,8 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Error"
"503":
$ref: "#/components/responses/ServiceUnavailable"
/v1/capsules/{id}/files/remove:
parameters:
@ -1698,6 +1820,8 @@ paths:
responses:
"204":
description: File or directory removed
"400":
$ref: "#/components/responses/BadRequest"
"404":
description: Capsule not found
content:
@ -1710,6 +1834,8 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Error"
"503":
$ref: "#/components/responses/ServiceUnavailable"
/v1/capsules/{id}/exec/stream:
parameters:
@ -1749,9 +1875,15 @@ paths:
```
The connection closes automatically after the process exits.
Note: capsule-not-found and not-running conditions encountered after the
WebSocket upgrade are delivered as in-band `{"type": "error"}` frames
rather than HTTP status codes.
responses:
"101":
description: WebSocket upgrade
"400":
$ref: "#/components/responses/BadRequest"
"404":
description: Capsule not found
content:
@ -1830,6 +1962,8 @@ paths:
responses:
"101":
description: WebSocket upgrade
"400":
$ref: "#/components/responses/BadRequest"
"404":
description: Capsule not found
content:
@ -1880,6 +2014,8 @@ paths:
responses:
"204":
description: File uploaded
"400":
$ref: "#/components/responses/BadRequest"
"404":
description: Capsule not found
content:
@ -1892,6 +2028,14 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Error"
"502":
description: Host agent error while streaming the upload
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"503":
$ref: "#/components/responses/ServiceUnavailable"
/v1/capsules/{id}/files/stream/read:
parameters:
@ -1925,6 +2069,8 @@ paths:
schema:
type: string
format: binary
"400":
$ref: "#/components/responses/BadRequest"
"404":
description: Capsule or file not found
content:
@ -1937,6 +2083,8 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Error"
"503":
$ref: "#/components/responses/ServiceUnavailable"
/v1/hosts:
post:
@ -2014,6 +2162,8 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Host"
"400":
$ref: "#/components/responses/BadRequest"
"404":
description: Host not found
content:
@ -2041,6 +2191,8 @@ paths:
responses:
"204":
description: Host deleted
"400":
$ref: "#/components/responses/BadRequest"
"403":
description: Insufficient permissions
content:
@ -2079,6 +2231,8 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/CreateHostResponse"
"400":
$ref: "#/components/responses/BadRequest"
"403":
description: Insufficient permissions
content:
@ -2148,6 +2302,8 @@ paths:
responses:
"204":
description: Heartbeat recorded
"400":
$ref: "#/components/responses/BadRequest"
"401":
description: Invalid or missing host token
content:
@ -2265,6 +2421,8 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HostDeletePreview"
"400":
$ref: "#/components/responses/BadRequest"
"403":
description: Insufficient permissions
content:
@ -2301,6 +2459,8 @@ paths:
type: array
items:
type: string
"400":
$ref: "#/components/responses/BadRequest"
post:
summary: Add a tag to a host
@ -2317,6 +2477,8 @@ paths:
responses:
"204":
description: Tag added
"400":
$ref: "#/components/responses/BadRequest"
"404":
description: Host not found
content:
@ -2346,6 +2508,8 @@ paths:
responses:
"204":
description: Tag removed
"400":
$ref: "#/components/responses/BadRequest"
"404":
description: Host not found
content:
@ -2441,6 +2605,8 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ChannelResponse"
"400":
$ref: "#/components/responses/BadRequest"
"404":
description: Channel not found
content:
@ -2483,6 +2649,14 @@ paths:
responses:
"204":
description: Channel deleted
"400":
$ref: "#/components/responses/BadRequest"
"404":
description: Channel not found
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/v1/channels/{id}/config:
parameters:
@ -2643,19 +2817,17 @@ paths:
schema:
type: object
properties:
entries:
items:
type: array
items:
$ref: "#/components/schemas/AuditLogEntry"
next_cursor:
type: object
nullable: true
properties:
before:
type: string
format: date-time
before_id:
type: string
next_before:
type: string
format: date-time
description: Cursor for the next page (pass as `before`). Present only when the page is non-empty.
next_before_id:
type: string
description: Tiebreaker cursor for the next page (pass as `before_id`). Present only when the page is non-empty.
/v1/admin/events/stream:
get:
@ -2704,10 +2876,17 @@ paths:
schema:
type: object
properties:
entries:
items:
type: array
items:
$ref: "#/components/schemas/AuditLogEntry"
next_before:
type: string
format: date-time
description: Cursor for the next page (pass as `before`). Present only when the page is non-empty.
next_before_id:
type: string
description: Tiebreaker cursor for the next page (pass as `before_id`). Present only when the page is non-empty.
/v1/admin/teams:
get:
@ -2918,8 +3097,8 @@ paths:
application/json:
schema: {type: object}
responses:
"202":
description: Build queued
"201":
description: Build created
content:
application/json:
schema: {type: object}
@ -3007,8 +3186,8 @@ paths:
schema:
$ref: "#/components/schemas/CreateCapsuleRequest"
responses:
"201":
description: Capsule created
"202":
description: Capsule creation accepted; provisioning asynchronously
content:
application/json:
schema:
@ -3048,6 +3227,10 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Capsule"
"400":
$ref: "#/components/responses/BadRequest"
"404":
$ref: "#/components/responses/NotFound"
delete:
summary: Destroy capsule (admin)
operationId: adminDestroyCapsule
@ -3055,8 +3238,16 @@ paths:
security:
- sessionAuth: []
responses:
"204":
description: Destroyed
"202":
description: Destroy accepted; teardown asynchronously
"400":
$ref: "#/components/responses/BadRequest"
"404":
$ref: "#/components/responses/NotFound"
"409":
$ref: "#/components/responses/FailedPrecondition"
"503":
$ref: "#/components/responses/ServiceUnavailable"
/v1/admin/capsules/{id}/snapshot:
post:
@ -3091,6 +3282,12 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Capsule"
"400":
$ref: "#/components/responses/BadRequest"
"404":
$ref: "#/components/responses/NotFound"
"409":
$ref: "#/components/responses/FailedPrecondition"
/v1/admin/capsules/{id}/exec:
parameters:
@ -3123,10 +3320,14 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/BackgroundExecResponse"
"400":
$ref: "#/components/responses/BadRequest"
"404":
$ref: "#/components/responses/NotFound"
"409":
$ref: "#/components/responses/FailedPrecondition"
"503":
$ref: "#/components/responses/ServiceUnavailable"
/v1/admin/capsules/{id}/metrics:
parameters:
@ -3155,8 +3356,12 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/CapsuleMetrics"
"400":
$ref: "#/components/responses/BadRequest"
"404":
$ref: "#/components/responses/NotFound"
"503":
$ref: "#/components/responses/ServiceUnavailable"
/v1/admin/capsules/{id}/processes:
parameters:
@ -3177,10 +3382,14 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ProcessListResponse"
"400":
$ref: "#/components/responses/BadRequest"
"404":
$ref: "#/components/responses/NotFound"
"409":
$ref: "#/components/responses/FailedPrecondition"
"503":
$ref: "#/components/responses/ServiceUnavailable"
/v1/admin/capsules/{id}/processes/{selector}:
parameters:
@ -3210,10 +3419,14 @@ paths:
responses:
"204":
description: Process killed
"400":
$ref: "#/components/responses/BadRequest"
"404":
$ref: "#/components/responses/NotFound"
"409":
$ref: "#/components/responses/FailedPrecondition"
"503":
$ref: "#/components/responses/ServiceUnavailable"
/v1/admin/capsules/{id}/files/write:
parameters:
@ -3240,8 +3453,20 @@ paths:
responses:
"204":
description: File uploaded
"400":
$ref: "#/components/responses/BadRequest"
"404":
$ref: "#/components/responses/NotFound"
"409":
$ref: "#/components/responses/FailedPrecondition"
"413":
description: File too large
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"503":
$ref: "#/components/responses/ServiceUnavailable"
/v1/admin/capsules/{id}/files/read:
parameters:
@ -3269,8 +3494,14 @@ paths:
schema:
type: string
format: binary
"400":
$ref: "#/components/responses/BadRequest"
"404":
$ref: "#/components/responses/NotFound"
"409":
$ref: "#/components/responses/FailedPrecondition"
"503":
$ref: "#/components/responses/ServiceUnavailable"
/v1/admin/capsules/{id}/files/list:
parameters:
@ -3297,10 +3528,14 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ListDirResponse"
"400":
$ref: "#/components/responses/BadRequest"
"404":
$ref: "#/components/responses/NotFound"
"409":
$ref: "#/components/responses/FailedPrecondition"
"503":
$ref: "#/components/responses/ServiceUnavailable"
/v1/admin/capsules/{id}/files/mkdir:
parameters:
@ -3327,10 +3562,14 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/MakeDirResponse"
"400":
$ref: "#/components/responses/BadRequest"
"404":
$ref: "#/components/responses/NotFound"
"409":
$ref: "#/components/responses/FailedPrecondition"
"503":
$ref: "#/components/responses/ServiceUnavailable"
/v1/admin/capsules/{id}/files/remove:
parameters:
@ -3353,10 +3592,14 @@ paths:
responses:
"204":
description: File or directory removed
"400":
$ref: "#/components/responses/BadRequest"
"404":
$ref: "#/components/responses/NotFound"
"409":
$ref: "#/components/responses/FailedPrecondition"
"503":
$ref: "#/components/responses/ServiceUnavailable"
/v1/admin/capsules/{id}/exec/stream:
parameters:
@ -3466,6 +3709,13 @@ components:
schema:
$ref: "#/components/schemas/Error"
ServiceUnavailable:
description: The host serving this capsule is unreachable (host_unavailable)
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
securitySchemes:
apiKeyAuth:
type: apiKey
@ -3568,6 +3818,12 @@ components:
key_prefix:
type: string
description: Display prefix (e.g. "wrn_ab12cd34...")
created_by:
type: string
description: ID of the user who created the key
creator_email:
type: string
description: Email of the creating user. Omitted if unavailable.
created_at:
type: string
format: date-time
@ -3601,6 +3857,16 @@ components:
after this duration of inactivity (no exec or ping). 0 means
no auto-pause. Positive values below 60 are silently clamped
to 60 (the agent's startup envelope).
metadata:
type: object
additionalProperties: {type: string}
nullable: true
description: >
Optional user-supplied key/value labels attached to the capsule.
Max 20 keys; keys match [a-zA-Z0-9][a-zA-Z0-9._-]{0,63}; values
are at most 64 characters. Reserved system keys (kernel_version,
vmm_version, agent_version, envd_version) are rejected. Metadata is
set at create-time only.
UsageResponse:
type: object
@ -3706,9 +3972,10 @@ components:
additionalProperties: {type: string}
nullable: true
description: |
Free-form key/value labels attached at create-time. Also carries
agent-side version info (kernel_version, vmm_version,
agent_version, envd_version) when running.
User-supplied key/value labels attached at create-time. Once the
capsule is running this also carries agent-side system version info
(kernel_version, vmm_version, agent_version, envd_version); those
reserved keys are owned by the system and cannot be set by users.
disk_size_mb:
type: integer
description: Maximum disk capacity in MiB.
@ -3822,10 +4089,10 @@ components:
type: object
additionalProperties:
type: string
description: Environment variables for the process (background exec only)
description: Environment variables for the process (applies to both foreground and background exec)
cwd:
type: string
description: Working directory for the process (background exec only)
description: Working directory for the process (applies to both foreground and background exec)
BackgroundExecResponse:
type: object
@ -4019,6 +4286,15 @@ components:
refresh_token:
type: string
description: Refresh token for obtaining new JWTs. Valid for 60 days; rotated on each use.
cert_pem:
type: string
description: mTLS client certificate (PEM). Only returned when the host enrolls into mTLS.
key_pem:
type: string
description: mTLS client private key (PEM). Only returned when the host enrolls into mTLS.
ca_cert_pem:
type: string
description: CA certificate (PEM) to verify the control plane. Only returned when the host enrolls into mTLS.
Host:
type: object
@ -4106,6 +4382,15 @@ components:
refresh_token:
type: string
description: New refresh token. Valid for 60 days; old token is revoked.
cert_pem:
type: string
description: mTLS client certificate (PEM). Only returned when the host enrolls into mTLS.
key_pem:
type: string
description: mTLS client private key (PEM). Only returned when the host enrolls into mTLS.
ca_cert_pem:
type: string
description: CA certificate (PEM) to verify the control plane. Only returned when the host enrolls into mTLS.
HostDeletePreview:
type: object
@ -4126,7 +4411,7 @@ components:
properties:
code:
type: string
example: host_has_sandboxes
example: has_active_sandboxes
message:
type: string
sandbox_ids:
@ -4160,6 +4445,9 @@ components:
slug:
type: string
description: Immutable 12-char hex slug (e.g. a1b2c3-d1e2f3)
is_byoc:
type: boolean
description: Whether the team runs on its own bring-your-own-cloud hosts
created_at:
type: string
format: date-time
@ -4178,6 +4466,8 @@ components:
properties:
user_id:
type: string
name:
type: string
email:
type: string
role:
@ -4371,11 +4661,22 @@ components:
MeResponse:
type: object
properties:
user_id:
type: string
team_id:
type: string
description: The user's currently active team
name:
type: string
email:
type: string
format: email
role:
type: string
description: The user's role within the active team
is_admin:
type: boolean
description: Whether the user is a platform administrator
has_password:
type: boolean
description: Whether the user has a password set (false for OAuth-only accounts)

View File

@ -49,6 +49,9 @@ type SandboxCreateParams struct {
VCPUs int32
MemoryMB int32
TimeoutSec int32
// Metadata holds user-supplied key/value labels attached at create-time.
// Reserved system keys (kernel_version, etc.) are rejected by validation.
Metadata map[string]string
}
// MinTimeoutSec mirrors internal/sandbox.MinTimeoutSec. Sub-minute TTLs race
@ -125,6 +128,9 @@ func (s *SandboxService) Create(ctx context.Context, p SandboxCreateParams) (db.
if err := validate.SafeName(p.Template); err != nil {
return db.Sandbox{}, fmt.Errorf("invalid template name: %w", err)
}
if err := validate.Metadata(p.Metadata); err != nil {
return db.Sandbox{}, fmt.Errorf("invalid metadata: %w", err)
}
if p.VCPUs <= 0 {
p.VCPUs = 1
}
@ -175,6 +181,13 @@ func (s *SandboxService) Create(ctx context.Context, p SandboxCreateParams) (db.
sandboxIDStr := id.FormatSandboxID(sandboxID)
hostIDStr := id.FormatHostID(host.ID)
metaJSON := []byte("{}")
if len(p.Metadata) > 0 {
if metaJSON, err = json.Marshal(p.Metadata); err != nil {
return db.Sandbox{}, fmt.Errorf("marshal metadata: %w", err)
}
}
sb, err := s.DB.InsertSandbox(ctx, db.InsertSandboxParams{
ID: sandboxID,
TeamID: p.TeamID,
@ -187,7 +200,7 @@ func (s *SandboxService) Create(ctx context.Context, p SandboxCreateParams) (db.
DiskSizeMb: 0,
TemplateID: templateID,
TemplateTeamID: templateTeamID,
Metadata: []byte("{}"),
Metadata: metaJSON,
})
if err != nil {
return db.Sandbox{}, fmt.Errorf("insert sandbox: %w", err)
@ -258,14 +271,7 @@ func (s *SandboxService) createInBackground(
slog.Warn("failed to update sandbox running after create", "id", sandboxIDStr, "error", dbErr)
}
if meta := resp.Msg.Metadata; len(meta) > 0 {
metaJSON, _ := json.Marshal(meta)
if err := s.DB.UpdateSandboxMetadata(bgCtx, db.UpdateSandboxMetadataParams{
ID: sandboxID, Metadata: metaJSON,
}); err != nil {
slog.Warn("failed to store sandbox metadata", "id", sandboxIDStr, "error", err)
}
}
s.persistSystemMetadata(bgCtx, sandboxID, sandboxIDStr, p.Metadata, resp.Msg.Metadata)
s.publishEvent(bgCtx, SandboxStateEvent{
Event: "sandbox.started", SandboxID: sandboxIDStr, TeamID: teamIDStr, HostID: hostIDStr,
@ -274,6 +280,33 @@ func (s *SandboxService) createInBackground(
})
}
// persistSystemMetadata merges agent-provided system metadata over the given
// user labels and writes the result to the sandbox row. System keys
// (kernel_version, etc.) are applied last so they always win — users can never
// override protected fields even if create-time validation were bypassed.
// userMeta may be nil. No-op when the agent returned no system metadata.
func (s *SandboxService) persistSystemMetadata(
ctx context.Context, sandboxID pgtype.UUID, sandboxIDStr string,
userMeta, systemMeta map[string]string,
) {
if len(systemMeta) == 0 {
return
}
merged := make(map[string]string, len(userMeta)+len(systemMeta))
for k, v := range userMeta {
merged[k] = v
}
for k, v := range systemMeta {
merged[k] = v
}
metaJSON, _ := json.Marshal(merged)
if err := s.DB.UpdateSandboxMetadata(ctx, db.UpdateSandboxMetadataParams{
ID: sandboxID, Metadata: metaJSON,
}); err != nil {
slog.Warn("failed to store sandbox metadata", "id", sandboxIDStr, "error", err)
}
}
// List returns active sandboxes (excludes stopped/error) belonging to the given team.
func (s *SandboxService) List(ctx context.Context, teamID pgtype.UUID) ([]db.Sandbox, error) {
return s.DB.ListSandboxesByTeam(ctx, teamID)
@ -447,13 +480,14 @@ func (s *SandboxService) resumeInBackground(
slog.Warn("failed to update sandbox to running after resume", "id", sandboxIDStr, "error", err)
}
if meta := resp.Msg.Metadata; len(meta) > 0 {
metaJSON, _ := json.Marshal(meta)
if err := s.DB.UpdateSandboxMetadata(bgCtx, db.UpdateSandboxMetadataParams{
ID: sandboxID, Metadata: metaJSON,
}); err != nil {
slog.Warn("failed to store sandbox metadata after resume", "id", sandboxIDStr, "error", err)
// Refresh system metadata but preserve the user's labels — read the current
// row and merge agent keys over it rather than overwriting wholesale.
if len(resp.Msg.Metadata) > 0 {
var existing map[string]string
if cur, err := s.DB.GetSandbox(bgCtx, sandboxID); err == nil && len(cur.Metadata) > 0 {
_ = json.Unmarshal(cur.Metadata, &existing)
}
s.persistSystemMetadata(bgCtx, sandboxID, sandboxIDStr, existing, resp.Msg.Metadata)
}
s.publishEvent(bgCtx, SandboxStateEvent{

53
pkg/validate/metadata.go Normal file
View File

@ -0,0 +1,53 @@
package validate
import (
"fmt"
"regexp"
"unicode/utf8"
)
// metadataKeyRe matches user metadata keys: alphanumeric start, then
// alphanumeric, dash, underscore, or dot. Max 64 characters. Mirrors the
// SafeName allowlist so keys stay predictable across the API and UI.
var metadataKeyRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)
const (
// MaxMetadataKeys caps how many user labels a sandbox may carry.
MaxMetadataKeys = 20
// MaxMetadataValueLen caps the length (in characters) of a label value.
MaxMetadataValueLen = 64
)
// reservedMetadataKeys are written by the host agent after a VM boots and must
// not be set by users — they carry the immutable system facts about the VM.
// Keep in sync with (*sandbox.Manager).buildMetadata: if a key is added there
// but not here, a user could set it at create-time and watch it silently get
// overwritten by the agent on boot. (validate cannot import internal/sandbox,
// hence the duplication — same pattern as service.MinTimeoutSec.)
var reservedMetadataKeys = map[string]struct{}{
"kernel_version": {},
"vmm_version": {},
"agent_version": {},
"envd_version": {},
}
// Metadata validates a user-supplied sandbox metadata map. It rejects reserved
// system keys, enforces a key count limit, and constrains key/value shape so
// the data stays safe to render and store. A nil/empty map is valid.
func Metadata(meta map[string]string) error {
if len(meta) > MaxMetadataKeys {
return fmt.Errorf("too many metadata keys: %d (max %d)", len(meta), MaxMetadataKeys)
}
for k, v := range meta {
if _, reserved := reservedMetadataKeys[k]; reserved {
return fmt.Errorf("metadata key %q is reserved for system use", k)
}
if !metadataKeyRe.MatchString(k) {
return fmt.Errorf("metadata key %q is invalid (max 64 chars, must match %s)", k, metadataKeyRe.String())
}
if utf8.RuneCountInString(v) > MaxMetadataValueLen {
return fmt.Errorf("metadata value for key %q is too long (max %d chars)", k, MaxMetadataValueLen)
}
}
return nil
}

View File

@ -0,0 +1,48 @@
package validate
import (
"fmt"
"strings"
"testing"
)
func TestMetadata(t *testing.T) {
tests := []struct {
name string
input map[string]string
wantErr bool
}{
{"nil", nil, false},
{"empty", map[string]string{}, false},
{"simple", map[string]string{"env": "prod", "owner": "alice"}, false},
{"dotted-key", map[string]string{"team.name": "infra"}, false},
{"empty-value", map[string]string{"flag": ""}, false},
{"max-keys", makeKeys(MaxMetadataKeys), false},
{"reserved-kernel", map[string]string{"kernel_version": "6.1.0"}, true},
{"reserved-vmm", map[string]string{"vmm_version": "x"}, true},
{"reserved-agent", map[string]string{"agent_version": "x"}, true},
{"reserved-envd", map[string]string{"envd_version": "x"}, true},
{"too-many-keys", makeKeys(MaxMetadataKeys + 1), true},
{"bad-key-leading-dot", map[string]string{".hidden": "v"}, true},
{"bad-key-space", map[string]string{"my key": "v"}, true},
{"key-too-long", map[string]string{strings.Repeat("a", 65): "v"}, true},
{"value-too-long", map[string]string{"k": strings.Repeat("a", MaxMetadataValueLen+1)}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := Metadata(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("Metadata(%v) error = %v, wantErr %v", tt.input, err, tt.wantErr)
}
})
}
}
func makeKeys(n int) map[string]string {
m := make(map[string]string, n)
for i := range n {
m[fmt.Sprintf("key%d", i)] = "v"
}
return m
}