diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8dc6f53..a72592d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,7 +13,7 @@ repos: - pydantic>=2.12.5 - httpx>=0.28.1 - httpx-ws>=0.9.0 - - email-validator>=2.3.0 + - types-PyYAML - repo: local hooks: diff --git a/Makefile b/Makefile index 047d79e..23f2acc 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ # Makefile -.PHONY: generate lint test check test-integration test-code-runner +.PHONY: generate gen-docs lint test check test-integration test-code-runner prune-spec # Variables SPEC_URL = "https://raw.githubusercontent.com/wrennhq/wrenn/refs/heads/main/internal/api/openapi.yaml" @@ -12,6 +12,8 @@ generate: curl -fsSL $(SPEC_URL) -o $(SPEC_PATH) + $(MAKE) prune-spec + uv run datamodel-codegen \ --input $(SPEC_PATH) \ --output src/wrenn/models/_generated.py \ @@ -40,6 +42,10 @@ test-code-runner: check: lint test +prune-spec: + @echo "Pruning spec down to the SDK's API-key surface..." + uv run python scripts/prune_openapi.py $(SPEC_PATH) + gen-docs: mkdir -p docs uv run pydoc-markdown > docs/reference.md diff --git a/api/openapi.yaml b/api/openapi.yaml index 0a4b218..7ed1e94 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1,1535 +1,693 @@ -openapi: "3.1.0" +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 - description: Local development - +- url: http://localhost:8080 + description: Local development security: [] - paths: - /v1/auth/signup: - post: - summary: Create a new account - operationId: signup - tags: [auth] - description: | - Creates an inactive user account and sends an activation email. - The user must activate their account within 30 minutes. - Does not return a JWT — the user must activate first, then sign in. - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/SignupRequest" - responses: - "201": - description: Account created, activation email sent - content: - application/json: - schema: - $ref: "#/components/schemas/SignupResponse" - "400": - description: Invalid request (bad email, short password) - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "409": - description: Email already registered or signup cooldown active - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /v1/auth/activate: - post: - summary: Activate account via email token - operationId: activate - tags: [auth] - description: | - Consumes the activation token sent via email and activates the user account. - Creates a default team and sets a session cookie to log the user in. - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [token] - properties: - token: - type: string - responses: - "200": - description: Account activated, session cookie set - content: - application/json: - schema: - $ref: "#/components/schemas/SessionResponse" - "400": - description: Invalid or expired token - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /v1/auth/logout: - post: - summary: Revoke the current session - operationId: logout - tags: [auth] - security: - - sessionAuth: [] - responses: - "204": - description: Session revoked; cookies cleared - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - - /v1/auth/logout-all: - post: - summary: Revoke every session for the current user - operationId: logoutAll - tags: [auth] - description: | - Revokes every active session for the calling user across all devices, - including the caller's own. Returns 204 and clears cookies on the - response. Triggered automatically by password change, password add, - and password reset. - security: - - sessionAuth: [] - responses: - "204": - description: All sessions revoked - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - - /v1/me/sessions: - get: - summary: List the caller's active sessions - operationId: listSessions - tags: [me] - security: - - sessionAuth: [] - responses: - "200": - description: Sessions list - content: - application/json: - schema: - type: object - properties: - sessions: - type: array - items: - type: object - properties: - id: - type: string - user_agent: - type: string - ip_address: - type: string - created_at: - type: string - format: date-time - last_seen_at: - type: string - format: date-time - expires_at: - type: string - format: date-time - current: - type: boolean - "401": - $ref: "#/components/responses/Unauthorized" - - /v1/me/sessions/{id}: - delete: - summary: Revoke a single session - operationId: revokeSession - tags: [me] - security: - - sessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - "204": - description: Session revoked - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - - /v1/auth/switch-team: - post: - summary: Switch active team - operationId: switchTeam - tags: [auth] - security: - - sessionAuth: [] - description: | - Rotates the session SID and updates its team scope. The user must be a - member of the target team (verified from DB). The new wrenn_sid and - wrenn_csrf cookies are set on the response. - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [team_id] - properties: - team_id: - type: string - responses: - "200": - description: New session issued for the target team; cookies refreshed - content: - application/json: - schema: - $ref: "#/components/schemas/SessionResponse" - "403": - description: Not a member of this team - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "404": - description: Team not found - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /v1/auth/login: - post: - summary: Log in with email and password - operationId: login - tags: [auth] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/LoginRequest" - responses: - "200": - description: Login successful - content: - application/json: - schema: - $ref: "#/components/schemas/SessionResponse" - "401": - description: Invalid credentials - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /auth/oauth/{provider}: - parameters: - - name: provider - in: path - required: true - schema: - type: string - enum: [github] - description: OAuth provider name - - get: - summary: Start OAuth login flow - operationId: oauthRedirect - tags: [auth] - description: | - Redirects the user to the OAuth provider's authorization page. - Sets a short-lived CSRF state cookie for validation on callback. - responses: - "302": - description: Redirect to provider authorization URL - "404": - description: Provider not found or not configured - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /auth/oauth/{provider}/callback: - parameters: - - name: provider - in: path - required: true - schema: - type: string - enum: [github] - description: OAuth provider name - - get: - summary: OAuth callback - operationId: oauthCallback - tags: [auth] - description: | - Handles the OAuth provider's callback after user authorization. - Exchanges the authorization code for a user profile, creates or - logs in the user, sets the wrenn_sid + wrenn_csrf cookies, and - redirects to the SPA callback page. - - **On success:** redirects to `{OAUTH_REDIRECT_URL}/auth/{provider}/callback` (no tokens in URL). - - **On error:** redirects to `{OAUTH_REDIRECT_URL}/auth/{provider}/callback?error=...` - - Possible error codes: `access_denied`, `invalid_state`, `missing_code`, - `exchange_failed`, `email_taken`, `internal_error`. - parameters: - - name: code - in: query - schema: - type: string - description: Authorization code from the OAuth provider - - name: state - in: query - schema: - type: string - description: CSRF state parameter (must match the cookie) - responses: - "302": - description: Redirect to frontend with token or error - - /v1/me: - get: - summary: Get current user profile - operationId: getMe - tags: [account] - security: - - sessionAuth: [] - responses: - "200": - description: User profile - content: - application/json: - schema: - $ref: "#/components/schemas/MeResponse" - - patch: - summary: Update display name - operationId: updateName - tags: [account] - security: - - sessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [name] - properties: - name: - type: string - minLength: 1 - maxLength: 100 - responses: - "204": - description: Name updated; session caches refreshed - "400": - description: Invalid name - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - delete: - summary: Delete current account - operationId: deleteAccount - tags: [account] - security: - - sessionAuth: [] - description: | - Soft-deletes the account (sets status=deleted, deleted_at=now). - The account is permanently removed after 15 days. Blocked if the user - owns any team that has other members. - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [confirmation] - properties: - confirmation: - type: string - description: Must match the user's email address (case-insensitive) - responses: - "204": - description: Account scheduled for deletion - "400": - description: Confirmation does not match email - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "409": - description: User owns teams with other members - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /v1/me/password: - post: - summary: Change or add password - operationId: changePassword - tags: [account] - security: - - sessionAuth: [] - description: | - For users with an existing password: requires `current_password` and `new_password`. - For OAuth-only users adding a password: requires `new_password` and `confirm_password`. - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/ChangePasswordRequest" - responses: - "204": - description: Password updated - "400": - description: Invalid request (short password, mismatch, etc.) - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "401": - description: Current password is incorrect - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /v1/me/password/reset: - post: - summary: Request a password reset email - operationId: requestPasswordReset - tags: [account] - description: | - Sends a password reset link to the given email. Always returns 200 - regardless of whether the email exists, to prevent account enumeration. - The reset token expires in 15 minutes. - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [email] - properties: - email: - type: string - format: email - responses: - "204": - description: Request accepted (email sent if account exists) - - /v1/me/password/reset/confirm: - post: - summary: Confirm password reset - operationId: confirmPasswordReset - tags: [account] - description: | - Consumes a password reset token and sets a new password. The token is - single-use and expires after 15 minutes. - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [token, new_password] - properties: - token: - type: string - description: Raw reset token from the email link - new_password: - type: string - minLength: 8 - responses: - "204": - description: Password reset successful - "400": - description: Invalid or expired token, or password too short - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /v1/me/providers/{provider}/connect: - parameters: - - name: provider - in: path - required: true - schema: - type: string - enum: [github] - description: OAuth provider name - - get: - summary: Initiate OAuth provider link - operationId: connectProvider - tags: [account] - security: - - sessionAuth: [] - description: | - Sets OAuth state and link cookies, then returns the provider's - authorization URL. The frontend navigates to this URL to start the - OAuth flow. On callback, the provider is linked to the current account - (not a new registration). - responses: - "200": - description: Authorization URL - content: - application/json: - schema: - type: object - properties: - auth_url: - type: string - format: uri - "404": - description: Provider not found or not configured - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /v1/me/providers/{provider}: - parameters: - - name: provider - in: path - required: true - schema: - type: string - enum: [github] - description: OAuth provider name - - delete: - summary: Disconnect an OAuth provider - operationId: disconnectProvider - tags: [account] - security: - - sessionAuth: [] - description: | - Unlinks the OAuth provider from the current account. Blocked if this - is the user's only login method (no password and no other providers). - responses: - "204": - description: Provider disconnected - "400": - description: Cannot disconnect last login method - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "404": - description: Provider not connected - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /v1/api-keys: - post: - summary: Create an API key - operationId: createAPIKey - tags: [api-keys] - security: - - sessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/CreateAPIKeyRequest" - responses: - "201": - description: API key created (plaintext key only shown once) - content: - application/json: - schema: - $ref: "#/components/schemas/APIKeyResponse" - "401": - description: Unauthorized - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - get: - summary: List API keys for your team - operationId: listAPIKeys - tags: [api-keys] - security: - - sessionAuth: [] - responses: - "200": - description: List of API keys (plaintext keys are never returned) - content: - application/json: - schema: - type: array - items: - $ref: "#/components/schemas/APIKeyResponse" - - /v1/api-keys/{id}: - parameters: - - name: id - in: path - required: true - schema: - type: string - - delete: - summary: Delete an API key - operationId: deleteAPIKey - tags: [api-keys] - security: - - sessionAuth: [] - responses: - "204": - description: API key deleted - - /v1/users/search: - get: - summary: Search users by email prefix - operationId: searchUsers - tags: [users] - security: - - sessionAuth: [] - description: | - Returns up to 10 users whose email starts with the given prefix. - The prefix must contain "@". Intended for the add-member UI autocomplete. - parameters: - - name: email - in: query - required: true - schema: - type: string - description: Email prefix (must contain "@", e.g. "alice@") - responses: - "200": - description: Matching users - content: - application/json: - schema: - type: array - items: - $ref: "#/components/schemas/UserSearchResult" - "400": - description: Prefix does not contain "@" - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /v1/teams: - get: - summary: List teams for the authenticated user - operationId: listTeams - tags: [teams] - security: - - sessionAuth: [] - responses: - "200": - description: Teams the user belongs to, each with their role - content: - application/json: - schema: - type: array - items: - $ref: "#/components/schemas/TeamWithRole" - - post: - summary: Create a new team - operationId: createTeam - tags: [teams] - security: - - sessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [name] - properties: - name: - type: string - description: 1-128 chars; A-Z a-z 0-9 space _ - responses: - "201": - description: Team created (caller is owner) - content: - application/json: - schema: - $ref: "#/components/schemas/TeamWithRole" - "400": - description: Invalid team name - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /v1/teams/{id}: - parameters: - - name: id - in: path - required: true - schema: - type: string - description: Team ID (must match the JWT's team_id) - - get: - summary: Get team info and member list - operationId: getTeam - tags: [teams] - security: - - sessionAuth: [] - responses: - "200": - description: Team details with members - content: - application/json: - schema: - $ref: "#/components/schemas/TeamDetail" - "403": - description: JWT team does not match requested team - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "404": - description: Team not found - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - patch: - summary: Rename the team - operationId: renameTeam - tags: [teams] - security: - - sessionAuth: [] - description: Admin or owner role required (verified from DB). - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [name] - properties: - name: - type: string - responses: - "204": - description: Renamed - "400": - description: Invalid team name - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "403": - description: Insufficient role - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - delete: - summary: Delete the team - operationId: deleteTeam - tags: [teams] - security: - - sessionAuth: [] - description: | - Owner only. Soft-deletes the team and destroys all running/paused/starting - capsules. All DB records are preserved. The team slug is permanently reserved. - responses: - "204": - description: Team deleted - "403": - description: Caller is not the owner - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /v1/teams/{id}/members: - parameters: - - name: id - in: path - required: true - schema: - type: string - - get: - summary: List team members - operationId: listTeamMembers - tags: [teams] - security: - - sessionAuth: [] - responses: - "200": - description: Members with roles - content: - application/json: - schema: - type: array - items: - $ref: "#/components/schemas/TeamMember" - - post: - summary: Add a member by email - operationId: addTeamMember - tags: [teams] - security: - - sessionAuth: [] - description: Admin or owner role required. User is added instantly as a member. - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [email] - properties: - email: - type: string - format: email - responses: - "201": - description: Member added - content: - application/json: - schema: - $ref: "#/components/schemas/TeamMember" - "403": - description: Insufficient role - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "404": - description: No account with that email - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "400": - description: User is already a member - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /v1/teams/{id}/members/{uid}: - parameters: - - name: id - in: path - required: true - schema: - type: string - - name: uid - in: path - required: true - schema: - type: string - description: Target user ID - - patch: - summary: Update member role - operationId: updateMemberRole - tags: [teams] - security: - - sessionAuth: [] - description: | - Admin or owner required. Valid target roles: admin, member. - The owner's role cannot be changed. - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [role] - properties: - role: - type: string - enum: [admin, member] - responses: - "204": - description: Role updated - "403": - description: Insufficient role or attempt to modify owner - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "404": - description: User is not a member - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - delete: - summary: Remove a member - operationId: removeTeamMember - tags: [teams] - security: - - sessionAuth: [] - description: Admin or owner required. Owner cannot be removed. - responses: - "204": - description: Member removed - "403": - description: Insufficient role or attempt to remove owner - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "404": - description: User is not a member - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /v1/teams/{id}/leave: - parameters: - - name: id - in: path - required: true - schema: - type: string - - post: - summary: Leave the team - operationId: leaveTeam - tags: [teams] - security: - - sessionAuth: [] - description: The owner cannot leave; they must delete the team instead. - responses: - "204": - description: Left the team - "403": - description: Owner cannot leave - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - /v1/capsules: post: summary: Create a capsule operationId: createCapsule - tags: [capsules] + tags: + - capsules security: - - apiKeyAuth: [] - - sessionAuth: [] + - apiKeyAuth: [] requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/CreateCapsuleRequest" + $ref: '#/components/schemas/CreateCapsuleRequest' responses: - "202": + '202': description: Capsule creation initiated (status will be "starting") content: application/json: schema: - $ref: "#/components/schemas/Capsule" - "502": + $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" - + $ref: '#/components/schemas/Error' + '503': + $ref: '#/components/responses/ServiceUnavailable' get: summary: List capsules for your team operationId: listCapsules - tags: [capsules] + tags: + - capsules security: - - apiKeyAuth: [] - - sessionAuth: [] + - apiKeyAuth: [] responses: - "200": + '200': description: List of capsules content: application/json: schema: type: array items: - $ref: "#/components/schemas/Capsule" - + $ref: '#/components/schemas/Capsule' /v1/capsules/stats: get: summary: Get capsule usage stats for your team operationId: getCapsuleStats - tags: [capsules] + tags: + - capsules security: - - apiKeyAuth: [] - - sessionAuth: [] + - apiKeyAuth: [] parameters: - - name: range - in: query - required: false - schema: - type: string - enum: [5m, 1h, 6h, 24h, 30d] - default: 1h - description: Time window for the time-series data. + - name: range + in: query + required: false + schema: + type: string + enum: + - 5m + - 1h + - 6h + - 24h + - 30d + default: 1h + description: Time window for the time-series data. responses: - "200": + '200': description: Capsule stats for the team content: application/json: schema: - $ref: "#/components/schemas/CapsuleStats" - "400": - $ref: "#/components/responses/BadRequest" - + $ref: '#/components/schemas/CapsuleStats' + '400': + $ref: '#/components/responses/BadRequest' /v1/capsules/usage: get: summary: Get daily CPU and RAM usage for your team operationId: getCapsuleUsage - tags: [capsules] + tags: + - capsules security: - - apiKeyAuth: [] - - sessionAuth: [] + - apiKeyAuth: [] parameters: - - name: from - in: query - required: false - schema: - type: string - format: date - description: Start date (YYYY-MM-DD). Defaults to 30 days ago. - - name: to - in: query - required: false - schema: - type: string - format: date - description: End date (YYYY-MM-DD). Defaults to today. + - name: from + in: query + required: false + schema: + type: string + format: date + description: Start date (YYYY-MM-DD). Defaults to 30 days ago. + - name: to + in: query + required: false + schema: + type: string + format: date + description: End date (YYYY-MM-DD). Defaults to today. responses: - "200": + '200': description: Daily usage data for the team content: application/json: schema: - $ref: "#/components/schemas/UsageResponse" - "400": - $ref: "#/components/responses/BadRequest" - + $ref: '#/components/schemas/UsageResponse' + '400': + $ref: '#/components/responses/BadRequest' /v1/capsules/{id}: parameters: - - name: id - in: path - required: true - schema: - type: string - + - name: id + in: path + required: true + schema: + type: string get: summary: Get capsule details operationId: getCapsule - tags: [capsules] + tags: + - capsules security: - - apiKeyAuth: [] - - sessionAuth: [] + - apiKeyAuth: [] responses: - "200": + '200': description: Capsule details content: application/json: schema: - $ref: "#/components/schemas/Capsule" - "404": + $ref: '#/components/schemas/Capsule' + '400': + $ref: '#/components/responses/BadRequest' + '404': description: Capsule not found content: application/json: schema: - $ref: "#/components/schemas/Error" - + $ref: '#/components/schemas/Error' delete: summary: Destroy a capsule operationId: destroyCapsule - tags: [capsules] + tags: + - capsules security: - - apiKeyAuth: [] - - sessionAuth: [] + - apiKeyAuth: [] responses: - "202": + '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: - - name: id - in: path - required: true - schema: - type: string - + - name: id + in: path + required: true + schema: + type: string post: summary: Execute a command operationId: execCommand - tags: [capsules] + tags: + - capsules security: - - apiKeyAuth: [] - - sessionAuth: [] + - apiKeyAuth: [] requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ExecRequest" + $ref: '#/components/schemas/ExecRequest' responses: - "200": + '200': description: Command output (foreground exec) content: application/json: schema: - $ref: "#/components/schemas/ExecResponse" - "202": + $ref: '#/components/schemas/ExecResponse' + '202': description: Background process started content: application/json: schema: - $ref: "#/components/schemas/BackgroundExecResponse" - "404": + $ref: '#/components/schemas/BackgroundExecResponse' + '400': + $ref: '#/components/responses/BadRequest' + '404': description: Capsule not found content: application/json: schema: - $ref: "#/components/schemas/Error" - "409": + $ref: '#/components/schemas/Error' + '409': description: Capsule not running content: application/json: schema: - $ref: "#/components/schemas/Error" - + $ref: '#/components/schemas/Error' + '503': + $ref: '#/components/responses/ServiceUnavailable' /v1/capsules/{id}/processes: parameters: - - name: id - in: path - required: true - schema: - type: string - + - name: id + in: path + required: true + schema: + type: string get: summary: List running processes operationId: listProcesses - tags: [capsules] + tags: + - capsules security: - - apiKeyAuth: [] - - sessionAuth: [] - description: | - Returns all running processes inside the capsule, including background + - apiKeyAuth: [] + description: 'Returns all running processes inside the capsule, including background + processes and any processes started by templates or init scripts. + + ' responses: - "200": + '200': description: Process list content: application/json: schema: - $ref: "#/components/schemas/ProcessListResponse" - "404": + $ref: '#/components/schemas/ProcessListResponse' + '400': + $ref: '#/components/responses/BadRequest' + '404': description: Capsule not found content: application/json: schema: - $ref: "#/components/schemas/Error" - "409": + $ref: '#/components/schemas/Error' + '409': description: Capsule not running content: application/json: schema: - $ref: "#/components/schemas/Error" - + $ref: '#/components/schemas/Error' + '503': + $ref: '#/components/responses/ServiceUnavailable' /v1/capsules/{id}/processes/{selector}: parameters: - - name: id - in: path - required: true - schema: - type: string - - name: selector - in: path - required: true - description: Process PID (numeric) or tag (string) - schema: - type: string - + - name: id + in: path + required: true + schema: + type: string + - name: selector + in: path + required: true + description: Process PID (numeric) or tag (string) + schema: + type: string delete: summary: Kill a process operationId: killProcess - tags: [capsules] + tags: + - capsules security: - - apiKeyAuth: [] - - sessionAuth: [] + - apiKeyAuth: [] parameters: - - name: signal - in: query - required: false - description: Signal to send (SIGKILL or SIGTERM, default SIGKILL) - schema: - type: string - enum: [SIGKILL, SIGTERM] - default: SIGKILL + - name: signal + in: query + required: false + description: Signal to send (SIGKILL or SIGTERM, default SIGKILL) + schema: + type: string + enum: + - SIGKILL + - SIGTERM + default: SIGKILL responses: - "204": + '204': description: Process killed - "404": + '400': + $ref: '#/components/responses/BadRequest' + '404': description: Capsule or process not found content: application/json: schema: - $ref: "#/components/schemas/Error" - "409": + $ref: '#/components/schemas/Error' + '409': description: Capsule not running content: application/json: schema: - $ref: "#/components/schemas/Error" - + $ref: '#/components/schemas/Error' + '503': + $ref: '#/components/responses/ServiceUnavailable' /v1/capsules/{id}/processes/{selector}/stream: parameters: - - name: id - in: path - required: true - schema: - type: string - - name: selector - in: path - required: true - description: Process PID (numeric) or tag (string) - schema: - type: string - + - name: id + in: path + required: true + schema: + type: string + - name: selector + in: path + required: true + description: Process PID (numeric) or tag (string) + schema: + type: string get: summary: Stream process output via WebSocket operationId: connectProcess - tags: [capsules] + tags: + - capsules security: - - apiKeyAuth: [] - - sessionAuth: [] - description: | - Opens a WebSocket connection to stream stdout/stderr from a running + - apiKeyAuth: [] + description: 'Opens a WebSocket connection to stream stdout/stderr from a running + background process. The selector can be a numeric PID or a string tag. - Server sends JSON messages: - - `{"type": "start", "pid": 42}` — connected to process - - `{"type": "stdout", "data": "..."}` — stdout output - - `{"type": "stderr", "data": "..."}` — stderr output - - `{"type": "exit", "exit_code": 0}` — process exited - - `{"type": "error", "data": "..."}` — error message - responses: - "101": - description: WebSocket upgrade + Server sends JSON messages: + + - `{"type": "start", "pid": 42}` — connected to process + + - `{"type": "stdout", "data": "..."}` — stdout output + + - `{"type": "stderr", "data": "..."}` — stderr output + + - `{"type": "exit", "exit_code": 0}` — process exited + + - `{"type": "error", "data": "..."}` — error message + + ' + responses: + '101': + description: WebSocket upgrade /v1/capsules/{id}/ping: parameters: - - name: id - in: path - required: true - schema: - type: string - + - name: id + in: path + required: true + schema: + type: string post: summary: Reset capsule inactivity timer operationId: pingCapsule - tags: [capsules] + tags: + - capsules security: - - apiKeyAuth: [] - - sessionAuth: [] - description: | - Resets the last_active_at timestamp for a running capsule, preventing + - apiKeyAuth: [] + description: 'Resets the last_active_at timestamp for a running capsule, preventing + the auto-pause TTL from expiring. Use this as a keepalive for capsules + that are idle but should remain running. + + ' responses: - "204": + '204': description: Ping acknowledged, inactivity timer reset - "404": + '400': + $ref: '#/components/responses/BadRequest' + '404': description: Capsule not found content: application/json: schema: - $ref: "#/components/schemas/Error" - "409": + $ref: '#/components/schemas/Error' + '409': description: Capsule not running content: application/json: schema: - $ref: "#/components/schemas/Error" - + $ref: '#/components/schemas/Error' /v1/capsules/{id}/metrics: parameters: - - name: id - in: path - required: true - schema: - type: string - + - name: id + in: path + required: true + schema: + type: string get: summary: Get per-capsule resource metrics operationId: getCapsuleMetrics - tags: [capsules] + tags: + - capsules security: - - apiKeyAuth: [] - - sessionAuth: [] - description: | - Returns time-series CPU, memory, and disk metrics for a capsule. + - apiKeyAuth: [] + description: 'Returns time-series CPU, memory, and disk metrics for a capsule. + Three tiers are available with different granularity and retention: + - `10m`: 500ms samples, last 10 minutes + - `2h`: 30-second averages, last 2 hours + - `24h`: 5-minute averages, last 24 hours - For running capsules, data comes from the host agent's in-memory + + For running capsules, data comes from the host agent''s in-memory + ring buffer. For paused capsules, data is read from persisted + snapshots in the database. Stopped/destroyed capsules return 404. + + ' parameters: - - name: range - in: query - required: false - schema: - type: string - enum: ["5m", "10m", "1h", "2h", "6h", "12h", "24h"] - default: "10m" - description: Time range filter to query + - name: range + in: query + required: false + schema: + type: string + enum: + - 5m + - 10m + - 1h + - 2h + - 6h + - 12h + - 24h + default: 10m + description: Time range filter to query responses: - "200": + '200': description: Metrics retrieved content: application/json: schema: - $ref: "#/components/schemas/CapsuleMetrics" - "400": + $ref: '#/components/schemas/CapsuleMetrics' + '400': description: Invalid range parameter content: application/json: schema: - $ref: "#/components/schemas/Error" - "404": + $ref: '#/components/schemas/Error' + '404': description: Capsule not found or metrics not available content: application/json: schema: - $ref: "#/components/schemas/Error" - + $ref: '#/components/schemas/Error' + '503': + $ref: '#/components/responses/ServiceUnavailable' /v1/capsules/{id}/pause: parameters: - - name: id - in: path - required: true - schema: - type: string - + - name: id + in: path + required: true + schema: + type: string post: summary: Pause a running capsule operationId: pauseCapsule - tags: [capsules] + tags: + - capsules security: - - apiKeyAuth: [] - - sessionAuth: [] - description: | - Takes a snapshot of the capsule (VM state + memory + rootfs), then + - apiKeyAuth: [] + description: 'Takes a snapshot of the capsule (VM state + memory + rootfs), then + destroys all running resources. The capsule exists only as files on + disk and can be resumed later. + + ' responses: - "202": + '202': description: Capsule pause initiated (status will be "pausing") content: application/json: schema: - $ref: "#/components/schemas/Capsule" - "409": + $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" - + $ref: '#/components/schemas/Error' + '503': + $ref: '#/components/responses/ServiceUnavailable' /v1/capsules/{id}/resume: parameters: - - name: id - in: path - required: true - schema: - type: string - + - name: id + in: path + required: true + schema: + type: string post: summary: Resume a paused capsule operationId: resumeCapsule - tags: [capsules] + tags: + - capsules security: - - apiKeyAuth: [] - - sessionAuth: [] - description: | - Restores a paused capsule from its snapshot. Cloud Hypervisor is + - apiKeyAuth: [] + description: 'Restores a paused capsule from its snapshot. Cloud Hypervisor is + relaunched in --restore mode with memory_restore_mode=ondemand so + guest pages fault in lazily via userfaultfd. The original network + slot (and host-reachable IP) is preserved across pause/resume. + + ' responses: - "202": + '202': description: Capsule resume initiated (status will be "resuming") content: application/json: schema: - $ref: "#/components/schemas/Capsule" - "409": + $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" - + $ref: '#/components/schemas/Error' + '503': + $ref: '#/components/responses/ServiceUnavailable' /v1/snapshots: post: summary: Create a snapshot template operationId: createSnapshot - tags: [snapshots] + tags: + - snapshots security: - - apiKeyAuth: [] - - sessionAuth: [] - description: | - Snapshot a capsule, processed asynchronously. The call returns + - apiKeyAuth: [] + description: 'Snapshot a capsule, processed asynchronously. The call returns + immediately with the capsule in the `snapshotting` state, then it + returns to its original state on completion. The capsule must be + `running` or `paused`. + A `running` capsule is snapshotted live: it briefly pauses while its VM + state + memory + flattened rootfs are written to a new template, then + resumes to `running`. A `paused` capsule is snapshotted directly from + its on-disk state without reviving the VM, and stays `paused`. + Because it is async, the response does NOT contain the template. Watch + for the `template.snapshot.create` SSE event (its `outcome` reports + success or failure) or poll `GET /v1/snapshots` to observe completion. + Snapshots are immutable: each call must use a fresh name. Re-using + an existing name returns 409 Conflict. + + ' requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/CreateSnapshotRequest" + $ref: '#/components/schemas/CreateSnapshotRequest' responses: - "202": + '202': description: Snapshot accepted; capsule is now snapshotting content: application/json: schema: - $ref: "#/components/schemas/Capsule" - "409": + $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: application/json: schema: - $ref: "#/components/schemas/Error" - + $ref: '#/components/schemas/Error' get: summary: List templates for your team operationId: listSnapshots - tags: [snapshots] + tags: + - snapshots security: - - apiKeyAuth: [] - - sessionAuth: [] + - apiKeyAuth: [] parameters: - - name: type - in: query - required: false - schema: - type: string - enum: [base, snapshot] - description: Filter by template type. + - name: type + in: query + required: false + schema: + type: string + enum: + - base + - snapshot + description: Filter by template type. responses: - "200": + '200': description: List of templates content: application/json: schema: type: array items: - $ref: "#/components/schemas/Template" - + $ref: '#/components/schemas/Template' /v1/snapshots/{name}: parameters: - - name: name - in: path - required: true - schema: - type: string - + - name: name + in: path + required: true + schema: + type: string delete: summary: Delete a snapshot template operationId: deleteSnapshot - tags: [snapshots] + tags: + - snapshots security: - - apiKeyAuth: [] - - sessionAuth: [] + - apiKeyAuth: [] description: Removes the snapshot files from disk and deletes the database record. responses: - "204": + '204': description: Snapshot deleted - "404": + '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" - + $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: - - name: id - in: path - required: true - schema: - type: string - + - name: id + in: path + required: true + schema: + type: string post: summary: Upload a file operationId: uploadFile - tags: [capsules] + tags: + - capsules security: - - apiKeyAuth: [] - - sessionAuth: [] + - apiKeyAuth: [] requestBody: required: true content: multipart/form-data: schema: type: object - required: [path, file] + required: + - path + - file properties: path: type: string @@ -1539,336 +697,350 @@ paths: format: binary description: File content responses: - "204": + '204': description: File uploaded - "409": + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + '409': description: Capsule not running content: application/json: schema: - $ref: "#/components/schemas/Error" - "413": + $ref: '#/components/schemas/Error' + '413': description: File too large content: application/json: schema: - $ref: "#/components/schemas/Error" - + $ref: '#/components/schemas/Error' + '503': + $ref: '#/components/responses/ServiceUnavailable' /v1/capsules/{id}/files/read: parameters: - - name: id - in: path - required: true - schema: - type: string - + - name: id + in: path + required: true + schema: + type: string post: summary: Download a file operationId: downloadFile - tags: [capsules] + tags: + - capsules security: - - apiKeyAuth: [] - - sessionAuth: [] + - apiKeyAuth: [] requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ReadFileRequest" + $ref: '#/components/schemas/ReadFileRequest' responses: - "200": + '200': description: File content content: application/octet-stream: schema: type: string format: binary - "404": + '400': + $ref: '#/components/responses/BadRequest' + '404': description: Capsule or file not found content: application/json: schema: - $ref: "#/components/schemas/Error" - + $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: - - name: id - in: path - required: true - schema: - type: string - + - name: id + in: path + required: true + schema: + type: string post: summary: List directory contents operationId: listDir - tags: [capsules] + tags: + - capsules security: - - apiKeyAuth: [] - - sessionAuth: [] + - apiKeyAuth: [] requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ListDirRequest" + $ref: '#/components/schemas/ListDirRequest' responses: - "200": + '200': description: Directory listing content: application/json: schema: - $ref: "#/components/schemas/ListDirResponse" - "404": + $ref: '#/components/schemas/ListDirResponse' + '400': + $ref: '#/components/responses/BadRequest' + '404': description: Capsule not found content: application/json: schema: - $ref: "#/components/schemas/Error" - "409": + $ref: '#/components/schemas/Error' + '409': description: Capsule not running content: application/json: schema: - $ref: "#/components/schemas/Error" - + $ref: '#/components/schemas/Error' + '503': + $ref: '#/components/responses/ServiceUnavailable' /v1/capsules/{id}/files/mkdir: parameters: - - name: id - in: path - required: true - schema: - type: string - + - name: id + in: path + required: true + schema: + type: string post: summary: Create a directory operationId: makeDir - tags: [capsules] + tags: + - capsules security: - - apiKeyAuth: [] - - sessionAuth: [] + - apiKeyAuth: [] requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/MakeDirRequest" + $ref: '#/components/schemas/MakeDirRequest' responses: - "200": + '200': description: Directory created content: application/json: schema: - $ref: "#/components/schemas/MakeDirResponse" - "404": + $ref: '#/components/schemas/MakeDirResponse' + '400': + $ref: '#/components/responses/BadRequest' + '404': description: Capsule not found content: application/json: schema: - $ref: "#/components/schemas/Error" - "409": - description: > - Capsule not running, or a directory already exists at the - target path (error code `already_exists`). + $ref: '#/components/schemas/Error' + '409': + description: 'Capsule not running, or a directory already exists at the target path (error code `already_exists`). + + ' content: application/json: schema: - $ref: "#/components/schemas/Error" - + $ref: '#/components/schemas/Error' + '503': + $ref: '#/components/responses/ServiceUnavailable' /v1/capsules/{id}/files/remove: parameters: - - name: id - in: path - required: true - schema: - type: string - + - name: id + in: path + required: true + schema: + type: string post: summary: Remove a file or directory operationId: removePath - tags: [capsules] + tags: + - capsules security: - - apiKeyAuth: [] - - sessionAuth: [] + - apiKeyAuth: [] requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/RemoveRequest" + $ref: '#/components/schemas/RemoveRequest' responses: - "204": + '204': description: File or directory removed - "404": + '400': + $ref: '#/components/responses/BadRequest' + '404': description: Capsule not found content: application/json: schema: - $ref: "#/components/schemas/Error" - "409": + $ref: '#/components/schemas/Error' + '409': description: Capsule not running content: application/json: schema: - $ref: "#/components/schemas/Error" - + $ref: '#/components/schemas/Error' + '503': + $ref: '#/components/responses/ServiceUnavailable' /v1/capsules/{id}/exec/stream: parameters: - - name: id - in: path - required: true - schema: - type: string - + - name: id + in: path + required: true + schema: + type: string get: summary: Stream command execution via WebSocket operationId: execStream - tags: [capsules] + tags: + - capsules security: - - apiKeyAuth: [] - - sessionAuth: [] - description: | - Opens a WebSocket connection for streaming command execution. + - apiKeyAuth: [] + description: 'Opens a WebSocket connection for streaming command execution. + **Client sends** (first message to start the process): + ```json + {"type": "start", "cmd": "tail", "args": ["-f", "/var/log/syslog"]} + ``` + **Client sends** (to stop the process): + ```json + {"type": "stop"} + ``` + **Server sends** (process events as they arrive): + ```json + {"type": "start", "pid": 1234} + {"type": "stdout", "data": "line of output\n"} + {"type": "stderr", "data": "warning message\n"} + {"type": "exit", "exit_code": 0} + {"type": "error", "data": "description of error"} + ``` + 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": + '101': description: WebSocket upgrade - "404": + '400': + $ref: '#/components/responses/BadRequest' + '404': description: Capsule not found content: application/json: schema: - $ref: "#/components/schemas/Error" - "409": + $ref: '#/components/schemas/Error' + '409': description: Capsule not running content: application/json: schema: - $ref: "#/components/schemas/Error" - + $ref: '#/components/schemas/Error' /v1/capsules/{id}/pty: parameters: - - name: id - in: path - required: true - schema: - type: string - + - name: id + in: path + required: true + schema: + type: string get: summary: Interactive PTY session via WebSocket operationId: ptySession - tags: [capsules] + tags: + - capsules security: - - apiKeyAuth: [] - - sessionAuth: [] - description: | - Opens a WebSocket connection for an interactive PTY (terminal) session. - Supports creating new sessions, sending input, resizing, killing, and - reconnecting to existing sessions. - - **Client sends** (first message — start a new PTY): - ```json - { - "type": "start", - "cmd": "/bin/bash", - "args": [], - "cols": 80, - "rows": 24, - "envs": {"TERM": "xterm-256color"}, - "cwd": "/home/user", - "user": "user" - } - ``` - All fields except `type` are optional. Defaults: cmd="/bin/bash", cols=80, rows=24. - - **Client sends** (first message — reconnect to existing PTY): - ```json - {"type": "connect", "tag": "pty-abc123de"} - ``` - - **Client sends** (after session is established): - ```json - {"type": "input", "data": ""} - {"type": "resize", "cols": 120, "rows": 40} - {"type": "kill"} - ``` - - **Server sends**: - ```json - {"type": "started", "tag": "pty-abc123de", "pid": 42} - {"type": "output", "data": ""} - {"type": "exit", "exit_code": 0} - {"type": "error", "data": "description", "fatal": true} - {"type": "ping"} - ``` - - PTY data (input and output) is base64-encoded because it contains raw - terminal bytes (escape sequences, control codes) that are not valid UTF-8. - - Sessions persist across WebSocket disconnections — the process keeps - running in the capsule. Use the `tag` from the "started" response to - reconnect later. + - apiKeyAuth: [] + description: "Opens a WebSocket connection for an interactive PTY (terminal) session.\nSupports creating new sessions,\ + \ sending input, resizing, killing, and\nreconnecting to existing sessions.\n\n**Client sends** (first message — start\ + \ a new PTY):\n```json\n{\n \"type\": \"start\",\n \"cmd\": \"/bin/bash\",\n \"args\": [],\n \"cols\": 80,\n \ + \ \"rows\": 24,\n \"envs\": {\"TERM\": \"xterm-256color\"},\n \"cwd\": \"/home/user\",\n \"user\": \"user\"\n}\n\ + ```\nAll fields except `type` are optional. Defaults: cmd=\"/bin/bash\", cols=80, rows=24.\n\n**Client sends** (first\ + \ message — reconnect to existing PTY):\n```json\n{\"type\": \"connect\", \"tag\": \"pty-abc123de\"}\n```\n\n**Client\ + \ sends** (after session is established):\n```json\n{\"type\": \"input\", \"data\": \"\"}\n\ + {\"type\": \"resize\", \"cols\": 120, \"rows\": 40}\n{\"type\": \"kill\"}\n```\n\n**Server sends**:\n```json\n{\"\ + type\": \"started\", \"tag\": \"pty-abc123de\", \"pid\": 42}\n{\"type\": \"output\", \"data\": \"\"}\n{\"type\": \"exit\", \"exit_code\": 0}\n{\"type\": \"error\", \"data\": \"description\", \"fatal\": true}\n\ + {\"type\": \"ping\"}\n```\n\nPTY data (input and output) is base64-encoded because it contains raw\nterminal bytes\ + \ (escape sequences, control codes) that are not valid UTF-8.\n\nSessions persist across WebSocket disconnections\ + \ — the process keeps\nrunning in the capsule. Use the `tag` from the \"started\" response to\nreconnect later.\n" responses: - "101": + '101': description: WebSocket upgrade - "404": + '400': + $ref: '#/components/responses/BadRequest' + '404': description: Capsule not found content: application/json: schema: - $ref: "#/components/schemas/Error" - "409": + $ref: '#/components/schemas/Error' + '409': description: Capsule not running content: application/json: schema: - $ref: "#/components/schemas/Error" - + $ref: '#/components/schemas/Error' /v1/capsules/{id}/files/stream/write: parameters: - - name: id - in: path - required: true - schema: - type: string - + - name: id + in: path + required: true + schema: + type: string post: summary: Upload a file (streaming) operationId: streamUploadFile - tags: [capsules] + tags: + - capsules security: - - apiKeyAuth: [] - - sessionAuth: [] - description: | - Streams file content to the capsule without buffering in memory. + - apiKeyAuth: [] + description: 'Streams file content to the capsule without buffering in memory. + Suitable for large files. Uses the same multipart/form-data format + as the non-streaming upload endpoint. + + ' requestBody: required: true content: multipart/form-data: schema: type: object - required: [path, file] + required: + - path + - file properties: path: type: string @@ -1878,1557 +1050,125 @@ paths: format: binary description: File content responses: - "204": + '204': description: File uploaded - "404": + '400': + $ref: '#/components/responses/BadRequest' + '404': description: Capsule not found content: application/json: schema: - $ref: "#/components/schemas/Error" - "409": + $ref: '#/components/schemas/Error' + '409': description: Capsule not running content: application/json: schema: - $ref: "#/components/schemas/Error" - + $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: - - name: id - in: path - required: true - schema: - type: string - + - name: id + in: path + required: true + schema: + type: string post: summary: Download a file (streaming) operationId: streamDownloadFile - tags: [capsules] + tags: + - capsules security: - - apiKeyAuth: [] - - sessionAuth: [] - description: | - Streams file content from the capsule without buffering in memory. + - apiKeyAuth: [] + description: 'Streams file content from the capsule without buffering in memory. + Suitable for large files. Returns raw bytes with chunked transfer encoding. + + ' requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ReadFileRequest" + $ref: '#/components/schemas/ReadFileRequest' responses: - "200": + '200': description: File content streamed in chunks content: application/octet-stream: schema: type: string format: binary - "404": + '400': + $ref: '#/components/responses/BadRequest' + '404': description: Capsule or file not found content: application/json: schema: - $ref: "#/components/schemas/Error" - "409": + $ref: '#/components/schemas/Error' + '409': description: Capsule not running content: application/json: schema: - $ref: "#/components/schemas/Error" - - /v1/hosts: - post: - summary: Create a host - operationId: createHost - tags: [hosts] - security: - - sessionAuth: [] - description: | - Creates a new host record and returns a one-time registration token. - Regular hosts can only be created by admins. BYOC hosts can be created - by admins or team owners. - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/CreateHostRequest" - responses: - "201": - description: Host created with registration token - content: - application/json: - schema: - $ref: "#/components/schemas/CreateHostResponse" - "400": - description: Invalid request - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "403": - description: Insufficient permissions - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - get: - summary: List hosts - operationId: listHosts - tags: [hosts] - security: - - sessionAuth: [] - description: | - Admins see all hosts. Non-admins see only BYOC hosts belonging to their team. - responses: - "200": - description: List of hosts - content: - application/json: - schema: - type: array - items: - $ref: "#/components/schemas/Host" - - /v1/hosts/{id}: - parameters: - - name: id - in: path - required: true - schema: - type: string - - get: - summary: Get host details - operationId: getHost - tags: [hosts] - security: - - sessionAuth: [] - responses: - "200": - description: Host details - content: - application/json: - schema: - $ref: "#/components/schemas/Host" - "404": - description: Host not found - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - delete: - summary: Delete a host - operationId: deleteHost - tags: [hosts] - security: - - sessionAuth: [] - description: | - Admins can delete any host. Team owners and admins can delete BYOC hosts - belonging to their team. Without `?force=true`, returns 409 if the host - has active capsules. With `?force=true`, destroys all capsules first. - parameters: - - name: force - in: query - required: false - schema: - type: boolean - description: If true, destroy all capsules on the host before deleting. - responses: - "204": - description: Host deleted - "403": - description: Insufficient permissions - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "409": - description: Host has active capsules (only when force is not set) - content: - application/json: - schema: - $ref: "#/components/schemas/HostHasCapsulesError" - - /v1/hosts/{id}/token: - parameters: - - name: id - in: path - required: true - schema: - type: string - - post: - summary: Regenerate registration token - operationId: regenerateHostToken - tags: [hosts] - security: - - sessionAuth: [] - description: | - Issues a new registration token for a host still in "pending" status. - Use this when a previous registration attempt failed after consuming - the original token. Same permission model as host creation. - responses: - "201": - description: New registration token issued - content: - application/json: - schema: - $ref: "#/components/schemas/CreateHostResponse" - "403": - description: Insufficient permissions - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "409": - description: Host is not in pending status - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /v1/hosts/register: - post: - summary: Register a host agent - operationId: registerHost - tags: [hosts] - description: | - Called by the host agent on first startup. Validates the one-time - registration token, records machine specs, sets the host status to - "online", and returns a long-lived JWT for subsequent API calls - (heartbeats). - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/RegisterHostRequest" - responses: - "201": - description: Host registered, JWT returned - content: - application/json: - schema: - $ref: "#/components/schemas/RegisterHostResponse" - "400": - description: Invalid request - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "401": - description: Invalid or expired registration token - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /v1/hosts/{id}/heartbeat: - parameters: - - name: id - in: path - required: true - schema: - type: string - - post: - summary: Host agent heartbeat - operationId: hostHeartbeat - tags: [hosts] - security: - - hostTokenAuth: [] - description: | - Updates the host's last_heartbeat_at timestamp. The host ID in the URL - must match the host ID in the JWT. - responses: - "204": - description: Heartbeat recorded - "401": - description: Invalid or missing host token - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "403": - description: Host ID mismatch - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /v1/hosts/sandbox-events: - post: - summary: Sandbox lifecycle event callback - operationId: sandboxEventCallback - tags: [hosts] - security: - - hostTokenAuth: [] - description: | - Receives autonomous lifecycle events from host agents (e.g. auto-pause - from the TTL reaper). The event is published to an internal Redis stream - for the control plane's event consumer to process. - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [event, sandbox_id, host_id] - properties: - event: - type: string - description: | - Lifecycle event type. Known values: - * `sandbox.auto_paused` — TTL reaper paused the capsule - * `sandbox.stopped` — autonomous destroy (crash/eviction) - * `sandbox.error` — VMM/crash watcher reported error - Unknown event names are accepted and forwarded to the - stream consumer as-is (future-compatible). - sandbox_id: - type: string - host_id: - type: string - timestamp: - type: integer - format: int64 - responses: - "204": - description: Event accepted - "400": - description: Invalid request - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "403": - description: Host ID mismatch - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /v1/hosts/auth/refresh: - post: - summary: Refresh host JWT - operationId: refreshHostToken - tags: [hosts] - description: | - Exchanges a refresh token for a new JWT and rotated refresh token. - The old refresh token is immediately revoked. No authentication required — - the refresh token itself is the credential. - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/RefreshHostTokenRequest" - responses: - "200": - description: New JWT and rotated refresh token - content: - application/json: - schema: - $ref: "#/components/schemas/RefreshHostTokenResponse" - "401": - description: Invalid, expired, or revoked refresh token - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /v1/hosts/{id}/delete-preview: - parameters: - - name: id - in: path - required: true - schema: - type: string - - get: - summary: Preview host deletion - operationId: getHostDeletePreview - tags: [hosts] - security: - - sessionAuth: [] - description: | - Returns the list of capsule IDs that would be destroyed if the host - were deleted with `?force=true`. No state is modified. - responses: - "200": - description: Deletion preview - content: - application/json: - schema: - $ref: "#/components/schemas/HostDeletePreview" - "403": - description: Insufficient permissions - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "404": - description: Host not found - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /v1/hosts/{id}/tags: - parameters: - - name: id - in: path - required: true - schema: - type: string - - get: - summary: List host tags - operationId: listHostTags - tags: [hosts] - security: - - sessionAuth: [] - responses: - "200": - description: List of tags - content: - application/json: - schema: - type: array - items: - type: string - - post: - summary: Add a tag to a host - operationId: addHostTag - tags: [hosts] - security: - - sessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/AddTagRequest" - responses: - "204": - description: Tag added - "404": - description: Host not found - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /v1/hosts/{id}/tags/{tag}: - parameters: - - name: id - in: path - required: true - schema: - type: string - - name: tag - in: path - required: true - schema: - type: string - - delete: - summary: Remove a tag from a host - operationId: removeHostTag - tags: [hosts] - security: - - sessionAuth: [] - responses: - "204": - description: Tag removed - "404": - description: Host not found - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /v1/channels: - post: - summary: Create a notification channel - operationId: createChannel - tags: [channels] - security: - - sessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/CreateChannelRequest" - responses: - "201": - description: Channel created - content: - application/json: - schema: - $ref: "#/components/schemas/ChannelResponse" - "400": - $ref: "#/components/responses/BadRequest" - get: - summary: List notification channels - operationId: listChannels - tags: [channels] - security: - - sessionAuth: [] - responses: - "200": - description: Channels list - content: - application/json: - schema: - type: array - items: - $ref: "#/components/schemas/ChannelResponse" - - /v1/channels/test: - post: - summary: Test a channel configuration - description: > - Sends a test notification using the provided provider and config without - saving anything. Use this to verify credentials before creating a channel. - operationId: testChannel - tags: [channels] - security: - - sessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/TestChannelRequest" - responses: - "200": - description: Test notification sent successfully - content: - application/json: - schema: - type: object - properties: - status: - type: string - example: ok - "400": - $ref: "#/components/responses/BadRequest" - - /v1/channels/{id}: - parameters: - - name: id - in: path - required: true - schema: - type: string - get: - summary: Get a notification channel - operationId: getChannel - tags: [channels] - security: - - sessionAuth: [] - responses: - "200": - description: Channel details - content: - application/json: - schema: - $ref: "#/components/schemas/ChannelResponse" - "404": - description: Channel not found - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - patch: - summary: Update a notification channel - operationId: updateChannel - tags: [channels] - security: - - sessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/UpdateChannelRequest" - responses: - "200": - description: Channel updated - content: - application/json: - schema: - $ref: "#/components/schemas/ChannelResponse" - "400": - $ref: "#/components/responses/BadRequest" - "404": - description: Channel not found - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - delete: - summary: Delete a notification channel - operationId: deleteChannel - tags: [channels] - security: - - sessionAuth: [] - responses: - "204": - description: Channel deleted - - /v1/channels/{id}/config: - parameters: - - name: id - in: path - required: true - schema: - type: string - put: - summary: Rotate channel secrets - description: > - Replaces the channel's provider configuration entirely with new secrets. - The previous config is discarded. Config fields must match the provider's - required fields. - operationId: rotateChannelConfig - tags: [channels] - security: - - sessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/RotateConfigRequest" - responses: - "200": - description: Config rotated - content: - application/json: - schema: - $ref: "#/components/schemas/ChannelResponse" - "400": - $ref: "#/components/responses/BadRequest" - "404": - description: Channel not found - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /v1/admin/users/{id}/admin: - put: - summary: Grant or revoke platform admin - operationId: setUserAdmin - tags: [admin] - description: | - Sets the platform admin flag on a user. Cannot remove the last admin. - Requires platform admin access. Session caches for the target user - are invalidated immediately so the flag flip takes effect on the - user's next request. - security: - - sessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - example: "usr-a1b2c3d4" - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [admin] - properties: - admin: - type: boolean - description: true to grant admin, false to revoke. - responses: - "204": - description: Admin status updated - "400": - $ref: "#/components/responses/BadRequest" - "403": - description: Caller is not a platform admin - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "404": - description: User not found - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - + $ref: '#/components/schemas/Error' + '503': + $ref: '#/components/responses/ServiceUnavailable' /v1/events/stream: get: summary: Real-time lifecycle event stream operationId: streamEvents - tags: [events] - description: | - Server-Sent Events stream of capsule, template, and host lifecycle - events scoped to the caller's active team. Browsers send the + tags: + - events + description: 'Server-Sent Events stream of capsule, template, and host lifecycle + + events scoped to the caller''s active team. Browsers send the + wrenn_sid cookie automatically on EventSource connections; SDKs + authenticate via X-API-Key. + Frame format follows the standard SSE protocol: + ``` + event: capsule.create + data: {"event":"capsule.create","outcome":"success","resource":{"id":"sb-..."},"sandbox":{...},"timestamp":"2026-05-19T02:00:00Z"} + : keepalive + ``` + A `: keepalive` comment is emitted every 30s. + + ' security: - - apiKeyAuth: [] - - sessionAuth: [] + - apiKeyAuth: [] responses: - "200": + '200': description: SSE stream opened content: text/event-stream: schema: - $ref: "#/components/schemas/SSEEvent" - "401": + $ref: '#/components/schemas/SSEEvent' + '401': description: Missing or invalid auth content: application/json: schema: - $ref: "#/components/schemas/Error" - - /v1/audit-logs: - get: - summary: List team audit log entries - operationId: listAuditLogs - tags: [audit] - description: Paginated cursor list of audit events for the caller's team. - security: - - sessionAuth: [] - parameters: - - name: before - in: query - required: false - schema: - type: string - format: date-time - - name: before_id - in: query - required: false - schema: - type: string - - name: limit - in: query - required: false - schema: - type: integer - minimum: 1 - maximum: 200 - default: 50 - responses: - "200": - description: Audit log page - content: - application/json: - schema: - type: object - properties: - entries: - type: array - items: - $ref: "#/components/schemas/AuditLogEntry" - next_cursor: - type: object - nullable: true - properties: - before: - type: string - format: date-time - before_id: - type: string - - /v1/admin/events/stream: - get: - summary: Admin SSE event stream (all teams) - operationId: adminStreamEvents - tags: [admin, events] - description: | - Admin variant of /v1/events/stream that emits events across all teams. - Requires an admin session cookie. - security: - - sessionAuth: [] - responses: - "200": - description: SSE stream opened - content: - text/event-stream: - schema: - $ref: "#/components/schemas/SSEEvent" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - - /v1/admin/audit-logs: - get: - summary: List audit log entries (all teams) - operationId: adminListAuditLogs - tags: [admin, audit] - security: - - sessionAuth: [] - parameters: - - name: before - in: query - schema: {type: string, format: date-time} - - name: before_id - in: query - schema: {type: string} - - name: limit - in: query - schema: {type: integer, minimum: 1, maximum: 200, default: 50} - responses: - "200": - description: Audit log page (all teams) - content: - application/json: - schema: - type: object - properties: - entries: - type: array - items: - $ref: "#/components/schemas/AuditLogEntry" - - /v1/admin/teams: - get: - summary: List all teams (admin) - operationId: adminListTeams - tags: [admin] - security: - - sessionAuth: [] - parameters: - - name: page - in: query - required: false - schema: - type: integer - minimum: 1 - default: 1 - description: Page number for pagination. - responses: - "200": - description: Paginated teams list - content: - application/json: - schema: - type: object - properties: - teams: - type: array - items: - $ref: "#/components/schemas/AdminTeam" - total: - type: integer - page: - type: integer - per_page: - type: integer - total_pages: - type: integer - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - - /v1/admin/teams/{id}/byoc: - put: - summary: Toggle BYOC for a team (admin) - operationId: adminSetTeamBYOC - tags: [admin] - security: - - sessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: {type: string} - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [enabled] - properties: - enabled: - type: boolean - description: true to enable BYOC, false to disable. - responses: - "204": - description: Updated - "400": - $ref: "#/components/responses/BadRequest" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - - /v1/admin/teams/{id}: - delete: - summary: Delete a team (admin) - operationId: adminDeleteTeam - tags: [admin] - security: - - sessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: {type: string} - responses: - "204": - description: Deleted - "400": - $ref: "#/components/responses/BadRequest" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "404": - $ref: "#/components/responses/NotFound" - - /v1/admin/hosts: - get: - summary: List all hosts (admin) - operationId: adminListHosts - tags: [admin] - security: - - sessionAuth: [] - description: | - Returns all hosts across all teams with per-host resource consumption. - Includes team name for hosts associated with a team. - responses: - "200": - description: Hosts list - content: - application/json: - schema: - type: array - items: - $ref: "#/components/schemas/Host" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - - /v1/admin/users: - get: - summary: List all users (admin) - operationId: adminListUsers - tags: [admin] - security: - - sessionAuth: [] - responses: - "200": - description: Users list - content: - application/json: - schema: - type: array - items: {type: object} - - /v1/admin/users/{id}/active: - put: - summary: Activate or deactivate a user (admin) - operationId: adminSetUserActive - tags: [admin] - security: - - sessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: {type: string} - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [active] - properties: - active: {type: boolean} - responses: - "204": - description: Updated - - /v1/admin/templates: - get: - summary: List all templates (admin) - operationId: adminListTemplates - tags: [admin] - security: - - sessionAuth: [] - responses: - "200": - description: Templates list - content: - application/json: - schema: - type: array - items: - $ref: "#/components/schemas/AdminTemplate" - - /v1/admin/templates/{name}: - delete: - summary: Delete a template (admin) - operationId: adminDeleteTemplate - tags: [admin] - security: - - sessionAuth: [] - parameters: - - name: name - in: path - required: true - schema: {type: string} - responses: - "204": - description: Deleted - - /v1/admin/builds: - post: - summary: Submit a template build (admin) - operationId: adminCreateBuild - tags: [admin] - security: - - sessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: {type: object} - responses: - "202": - description: Build queued - content: - application/json: - schema: {type: object} - get: - summary: List builds (admin) - operationId: adminListBuilds - tags: [admin] - security: - - sessionAuth: [] - responses: - "200": - description: Builds list - content: - application/json: - schema: - type: array - items: {type: object} - - /v1/admin/builds/{id}: - get: - summary: Get build detail (admin) - operationId: adminGetBuild - tags: [admin] - security: - - sessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: {type: string} - responses: - "200": - description: Build detail - content: - application/json: - schema: {type: object} - - /v1/admin/builds/{id}/cancel: - post: - summary: Cancel a build (admin) - operationId: adminCancelBuild - tags: [admin] - security: - - sessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: {type: string} - responses: - "204": - description: Cancelled - - /v1/admin/builds/{id}/stream: - get: - summary: Stream a build's live console (admin, WebSocket) - description: > - WebSocket endpoint. On connect, replays the completed-step history, - then live-tails JSON events (step-start, output, step-end, - build-status, ping) until the build finishes. - operationId: adminStreamBuild - tags: [admin] - security: - - sessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: {type: string} - responses: - "101": - description: WebSocket upgrade — streams build console events - - /v1/admin/capsules: - post: - summary: Create a capsule on behalf of any team (admin) - operationId: adminCreateCapsule - tags: [admin] - security: - - sessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/CreateCapsuleRequest" - responses: - "201": - description: Capsule created - content: - application/json: - schema: - $ref: "#/components/schemas/Capsule" - get: - summary: List capsules across all teams (admin) - operationId: adminListCapsules - tags: [admin] - security: - - sessionAuth: [] - responses: - "200": - description: Capsules list - content: - application/json: - schema: - type: array - items: - $ref: "#/components/schemas/Capsule" - - /v1/admin/capsules/{id}: - parameters: - - name: id - in: path - required: true - schema: {type: string} - get: - summary: Get capsule detail (admin) - operationId: adminGetCapsule - tags: [admin] - security: - - sessionAuth: [] - responses: - "200": - description: Capsule detail - content: - application/json: - schema: - $ref: "#/components/schemas/Capsule" - delete: - summary: Destroy capsule (admin) - operationId: adminDestroyCapsule - tags: [admin] - security: - - sessionAuth: [] - responses: - "204": - description: Destroyed - - /v1/admin/capsules/{id}/snapshot: - post: - summary: Create snapshot from any capsule (admin) - operationId: adminCreateSnapshotFromCapsule - tags: [admin] - description: | - Snapshots a `running` or `paused` capsule into a platform template, - processed asynchronously (see `POST /v1/snapshots`). A running capsule - resumes to `running`; a paused capsule stays `paused`. - security: - - sessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: {type: string} - requestBody: - required: false - content: - application/json: - schema: - type: object - properties: - name: - type: string - description: Optional; an auto-generated name is used when omitted. - responses: - "202": - description: Snapshot accepted; capsule is now snapshotting - content: - application/json: - schema: - $ref: "#/components/schemas/Capsule" - - /v1/admin/capsules/{id}/exec: - parameters: - - name: id - in: path - required: true - schema: {type: string} - post: - summary: Execute a command on any capsule (admin) - operationId: adminExecCommand - tags: [admin] - security: - - sessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/ExecRequest" - responses: - "200": - description: Command output (foreground exec) - content: - application/json: - schema: - $ref: "#/components/schemas/ExecResponse" - "202": - description: Background process started - content: - application/json: - schema: - $ref: "#/components/schemas/BackgroundExecResponse" - "404": - $ref: "#/components/responses/NotFound" - "409": - $ref: "#/components/responses/FailedPrecondition" - - /v1/admin/capsules/{id}/metrics: - parameters: - - name: id - in: path - required: true - schema: {type: string} - get: - summary: Get per-capsule resource metrics (admin) - operationId: adminGetCapsuleMetrics - tags: [admin] - security: - - sessionAuth: [] - parameters: - - name: range - in: query - required: false - schema: - type: string - enum: ["5m", "10m", "1h", "2h", "6h", "12h", "24h"] - default: "10m" - responses: - "200": - description: Metrics retrieved - content: - application/json: - schema: - $ref: "#/components/schemas/CapsuleMetrics" - "404": - $ref: "#/components/responses/NotFound" - - /v1/admin/capsules/{id}/processes: - parameters: - - name: id - in: path - required: true - schema: {type: string} - get: - summary: List running processes on any capsule (admin) - operationId: adminListProcesses - tags: [admin] - security: - - sessionAuth: [] - responses: - "200": - description: Process list - content: - application/json: - schema: - $ref: "#/components/schemas/ProcessListResponse" - "404": - $ref: "#/components/responses/NotFound" - "409": - $ref: "#/components/responses/FailedPrecondition" - - /v1/admin/capsules/{id}/processes/{selector}: - parameters: - - name: id - in: path - required: true - schema: {type: string} - - name: selector - in: path - required: true - schema: {type: string} - description: Process PID (numeric) or tag (string) - delete: - summary: Kill a process on any capsule (admin) - operationId: adminKillProcess - tags: [admin] - security: - - sessionAuth: [] - parameters: - - name: signal - in: query - required: false - schema: - type: string - enum: [SIGKILL, SIGTERM] - default: SIGKILL - responses: - "204": - description: Process killed - "404": - $ref: "#/components/responses/NotFound" - "409": - $ref: "#/components/responses/FailedPrecondition" - - /v1/admin/capsules/{id}/files/write: - parameters: - - name: id - in: path - required: true - schema: {type: string} - post: - summary: Upload a file to any capsule (admin) - operationId: adminUploadFile - tags: [admin] - security: - - sessionAuth: [] - requestBody: - required: true - content: - multipart/form-data: - schema: - type: object - required: [path, file] - properties: - path: {type: string} - file: {type: string, format: binary} - responses: - "204": - description: File uploaded - "409": - $ref: "#/components/responses/FailedPrecondition" - - /v1/admin/capsules/{id}/files/read: - parameters: - - name: id - in: path - required: true - schema: {type: string} - post: - summary: Download a file from any capsule (admin) - operationId: adminDownloadFile - tags: [admin] - security: - - sessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/ReadFileRequest" - responses: - "200": - description: File content - content: - application/octet-stream: - schema: - type: string - format: binary - "404": - $ref: "#/components/responses/NotFound" - - /v1/admin/capsules/{id}/files/list: - parameters: - - name: id - in: path - required: true - schema: {type: string} - post: - summary: List directory contents on any capsule (admin) - operationId: adminListDir - tags: [admin] - security: - - sessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/ListDirRequest" - responses: - "200": - description: Directory listing - content: - application/json: - schema: - $ref: "#/components/schemas/ListDirResponse" - "404": - $ref: "#/components/responses/NotFound" - "409": - $ref: "#/components/responses/FailedPrecondition" - - /v1/admin/capsules/{id}/files/mkdir: - parameters: - - name: id - in: path - required: true - schema: {type: string} - post: - summary: Create a directory on any capsule (admin) - operationId: adminMakeDir - tags: [admin] - security: - - sessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/MakeDirRequest" - responses: - "200": - description: Directory created - content: - application/json: - schema: - $ref: "#/components/schemas/MakeDirResponse" - "404": - $ref: "#/components/responses/NotFound" - "409": - $ref: "#/components/responses/FailedPrecondition" - - /v1/admin/capsules/{id}/files/remove: - parameters: - - name: id - in: path - required: true - schema: {type: string} - post: - summary: Remove a file or directory on any capsule (admin) - operationId: adminRemovePath - tags: [admin] - security: - - sessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/RemoveRequest" - responses: - "204": - description: File or directory removed - "404": - $ref: "#/components/responses/NotFound" - "409": - $ref: "#/components/responses/FailedPrecondition" - - /v1/admin/capsules/{id}/exec/stream: - parameters: - - name: id - in: path - required: true - schema: {type: string} - get: - summary: Stream command execution on any capsule via WebSocket (admin) - operationId: adminExecStream - tags: [admin] - security: - - sessionAuth: [] - description: | - Admin variant of /v1/capsules/{id}/exec/stream. Same protocol — WebSocket - upgrade, client sends `{"type":"start", "cmd":..., "args":...}` to start; - server streams stdout/stderr/exit frames. - responses: - "101": - description: WebSocket upgrade - "404": - $ref: "#/components/responses/NotFound" - "409": - $ref: "#/components/responses/FailedPrecondition" - - /v1/admin/capsules/{id}/pty: - parameters: - - name: id - in: path - required: true - schema: {type: string} - get: - summary: Interactive PTY session on any capsule via WebSocket (admin) - operationId: adminPtySession - tags: [admin] - security: - - sessionAuth: [] - description: | - Admin variant of /v1/capsules/{id}/pty. Same protocol — base64-encoded - PTY bytes, start/connect/input/resize/kill control messages, persistent - sessions reconnectable via tag. - responses: - "101": - description: WebSocket upgrade - "404": - $ref: "#/components/responses/NotFound" - "409": - $ref: "#/components/responses/FailedPrecondition" - - /v1/admin/capsules/{id}/processes/{selector}/stream: - parameters: - - name: id - in: path - required: true - schema: {type: string} - - name: selector - in: path - required: true - schema: {type: string} - description: Process PID (numeric) or tag (string) - get: - summary: Stream process output on any capsule via WebSocket (admin) - operationId: adminConnectProcess - tags: [admin] - security: - - sessionAuth: [] - responses: - "101": - description: WebSocket upgrade - "404": - $ref: "#/components/responses/NotFound" - + $ref: '#/components/schemas/Error' components: responses: BadRequest: @@ -3436,150 +1176,38 @@ components: content: application/json: schema: - $ref: "#/components/schemas/Error" - - Unauthorized: - description: Missing or invalid auth - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - + $ref: '#/components/schemas/Error' Forbidden: description: Authenticated but not permitted (e.g. non-admin on /v1/admin/*) content: application/json: schema: - $ref: "#/components/schemas/Error" - + $ref: '#/components/schemas/Error' NotFound: description: Resource not found content: application/json: schema: - $ref: "#/components/schemas/Error" - + $ref: '#/components/schemas/Error' FailedPrecondition: description: Resource state does not allow this operation (e.g. exec on a paused capsule) content: application/json: schema: - $ref: "#/components/schemas/Error" - + $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 in: header name: X-API-Key description: API key for capsule lifecycle operations. Create via POST /v1/api-keys. - - sessionAuth: - type: apiKey - in: cookie - name: wrenn_sid - description: | - Opaque session cookie set by POST /v1/auth/login, /v1/auth/activate, or - the OAuth callback. HttpOnly, Secure, SameSite=Strict. Idle window 6h, - absolute lifetime 24h. State-changing requests also require an - X-CSRF-Token header matching the wrenn_csrf cookie (double-submit). - csrfHeader: - type: apiKey - in: header - name: X-CSRF-Token - description: | - Double-submit CSRF token whose value must match the wrenn_csrf cookie. - Required on all non-GET requests authenticated via session cookie. - Not required for API key auth. - - hostTokenAuth: - type: apiKey - in: header - name: X-Host-Token - description: Host JWT returned from POST /v1/hosts/register or POST /v1/hosts/auth/refresh. Valid for 7 days. - schemas: - SignupRequest: - type: object - required: [email, password, name] - properties: - email: - type: string - format: email - password: - type: string - minLength: 8 - name: - type: string - maxLength: 100 - - LoginRequest: - type: object - required: [email, password] - properties: - email: - type: string - format: email - password: - type: string - - SignupResponse: - type: object - properties: - message: - type: string - description: Confirmation message instructing user to check email - - SessionResponse: - type: object - description: | - Returned by login, activate, and switch-team. The actual auth credential - is the wrenn_sid cookie set on the response. The body carries identity - data the SPA needs to bootstrap. - properties: - user_id: - type: string - team_id: - type: string - email: - type: string - name: - type: string - role: - type: string - is_admin: - type: boolean - - CreateAPIKeyRequest: - type: object - properties: - name: - type: string - default: Unnamed API Key - - APIKeyResponse: - type: object - properties: - id: - type: string - team_id: - type: string - name: - type: string - key_prefix: - type: string - description: Display prefix (e.g. "wrn_ab12cd34...") - created_at: - type: string - format: date-time - last_used: - type: string - format: date-time - nullable: true - key: - type: string - description: Full plaintext key. Only returned on creation, never again. - nullable: true - CreateCapsuleRequest: type: object properties: @@ -3592,23 +1220,25 @@ components: memory_mb: type: integer default: 512 - disk_size_mb: - type: integer - default: 5120 - description: > - Maximum size of the per-capsule copy-on-write disk in MB. Capped - at 5 GB by default; the actual size is max(disk_size_mb, origin - rootfs size). timeout_sec: type: integer minimum: 0 default: 0 - description: > - Auto-pause TTL in seconds. The capsule is automatically paused - 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). + description: 'Auto-pause TTL in seconds. The capsule is automatically paused 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 properties: @@ -3630,13 +1260,17 @@ components: type: number ram_mb_minutes: type: number - CapsuleStats: type: object properties: range: type: string - enum: [5m, 1h, 6h, 24h, 30d] + enum: + - 5m + - 1h + - 6h + - 24h + - 30d current: type: object properties: @@ -3677,7 +1311,6 @@ components: type: array items: type: integer - Capsule: type: object properties: @@ -3685,7 +1318,19 @@ components: type: string status: type: string - enum: [pending, starting, running, pausing, paused, snapshotting, resuming, stopping, hibernated, stopped, missing, error] + enum: + - pending + - starting + - running + - pausing + - paused + - snapshotting + - resuming + - stopping + - hibernated + - stopped + - missing + - error template: type: string vcpus: @@ -3710,12 +1355,18 @@ components: format: date-time metadata: type: object - additionalProperties: {type: string} + 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. + description: '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. @@ -3723,10 +1374,10 @@ components: type: integer format: int64 description: Current disk usage in MiB. Only populated on individual capsule GET; omitted in list responses. - CreateSnapshotRequest: type: object - required: [sandbox_id] + required: + - sandbox_id properties: sandbox_id: type: string @@ -3734,7 +1385,6 @@ components: name: type: string description: Name for the snapshot template. Auto-generated if omitted. - Template: type: object properties: @@ -3742,7 +1392,9 @@ components: type: string type: type: string - enum: [base, snapshot] + enum: + - base + - snapshot vcpus: type: integer nullable: true @@ -3757,56 +1409,31 @@ components: format: date-time platform: type: boolean - description: | - True when the template is platform-managed (visible to all teams, + description: 'True when the template is platform-managed (visible to all teams, + e.g. the built-in `minimal-ubuntu` rootfs). False for team-owned + snapshot templates. + + ' protected: type: boolean - description: | - True for built-in system base templates (minimal-ubuntu, + description: 'True for built-in system base templates (minimal-ubuntu, + minimal-alpine, minimal-arch, minimal-fedora). Protected templates + cannot be deleted. + + ' metadata: type: object - additionalProperties: {type: string} + additionalProperties: + type: string nullable: true - - AdminTemplate: - type: object - description: | - Template as returned by the admin templates list. Unlike `Template` - (the team-facing snapshot shape), this includes the owning `team_id` - and omits `platform`/`metadata`. - properties: - name: - type: string - type: - type: string - enum: [base, snapshot] - vcpus: - type: integer - memory_mb: - type: integer - size_bytes: - type: integer - format: int64 - team_id: - type: string - description: Owning team ID (formatted, e.g. `team-…`). Platform team for global templates. - created_at: - type: string - format: date-time - protected: - type: boolean - description: | - True for built-in system base templates (minimal-ubuntu, - minimal-alpine, minimal-arch, minimal-fedora). Protected templates - cannot be deleted. - ExecRequest: type: object - required: [cmd] + required: + - cmd properties: cmd: type: string @@ -3824,16 +1451,16 @@ components: description: If true, starts the process in the background and returns immediately with a PID and tag (HTTP 202) tag: type: string - description: Optional user-chosen tag for the background process. Auto-generated if omitted. Only used when background is true. + description: Optional user-chosen tag for the background process. Auto-generated if omitted. Only used when background + is true. envs: 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 properties: @@ -3845,7 +1472,6 @@ components: type: integer tag: type: string - ProcessEntry: type: object properties: @@ -3859,15 +1485,13 @@ components: type: array items: type: string - ProcessListResponse: type: object properties: processes: type: array items: - $ref: "#/components/schemas/ProcessEntry" - + $ref: '#/components/schemas/ProcessEntry' ExecResponse: type: object properties: @@ -3885,20 +1509,22 @@ components: type: integer encoding: type: string - enum: [utf-8, base64] + enum: + - utf-8 + - base64 description: Output encoding. "base64" when stdout/stderr contain binary data. - ReadFileRequest: type: object - required: [path] + required: + - path properties: path: type: string description: Absolute file path inside the capsule - ListDirRequest: type: object - required: [path] + required: + - path properties: path: type: string @@ -3907,15 +1533,13 @@ components: type: integer default: 1 description: Recursion depth (0 = non-recursive, 1 = immediate children) - ListDirResponse: type: object properties: entries: type: array items: - $ref: "#/components/schemas/FileEntry" - + $ref: '#/components/schemas/FileEntry' FileEntry: type: object properties: @@ -3925,7 +1549,10 @@ components: type: string type: type: string - enum: [file, directory, symlink] + enum: + - file + - directory + - symlink size: type: integer format: int64 @@ -3945,298 +1572,27 @@ components: symlink_target: type: string nullable: true - MakeDirRequest: type: object - required: [path] + required: + - path properties: path: type: string description: Directory path to create inside the capsule - MakeDirResponse: type: object properties: entry: - $ref: "#/components/schemas/FileEntry" - + $ref: '#/components/schemas/FileEntry' RemoveRequest: type: object - required: [path] + required: + - path properties: path: type: string description: Path to remove inside the capsule - - CreateHostRequest: - type: object - required: [type] - properties: - type: - type: string - enum: [regular, byoc] - description: Host type. Regular hosts are shared; BYOC hosts belong to a team. - team_id: - type: string - description: Required for BYOC hosts. - provider: - type: string - description: Cloud provider (e.g. aws, gcp, hetzner, bare-metal). - availability_zone: - type: string - description: Availability zone (e.g. us-east, eu-west). - - CreateHostResponse: - type: object - properties: - host: - $ref: "#/components/schemas/Host" - registration_token: - type: string - description: One-time registration token for the host agent. Expires in 1 hour. - - RegisterHostRequest: - type: object - required: [token, address] - properties: - token: - type: string - description: One-time registration token from POST /v1/hosts. - arch: - type: string - description: CPU architecture (e.g. x86_64, aarch64). - cpu_cores: - type: integer - memory_mb: - type: integer - disk_gb: - type: integer - address: - type: string - description: Host agent address (ip:port). - - RegisterHostResponse: - type: object - properties: - host: - $ref: "#/components/schemas/Host" - token: - type: string - description: Host JWT for X-Host-Token header. Valid for 7 days. - refresh_token: - type: string - description: Refresh token for obtaining new JWTs. Valid for 60 days; rotated on each use. - - Host: - type: object - properties: - id: - type: string - type: - type: string - enum: [regular, byoc] - team_id: - type: string - nullable: true - provider: - type: string - nullable: true - availability_zone: - type: string - nullable: true - arch: - type: string - nullable: true - cpu_cores: - type: integer - nullable: true - memory_mb: - type: integer - nullable: true - disk_gb: - type: integer - nullable: true - address: - type: string - nullable: true - status: - type: string - enum: [pending, online, offline, draining, unreachable] - last_heartbeat_at: - type: string - format: date-time - nullable: true - created_by: - type: string - created_at: - type: string - format: date-time - updated_at: - type: string - format: date-time - team_name: - type: string - nullable: true - description: Team name (included when listing hosts as an admin). - running_vcpus: - type: integer - description: Total vCPUs allocated to running capsules on this host. - running_memory_mb: - type: integer - description: Total memory in MB allocated to running capsules on this host. - running_disk_mb: - type: integer - description: Total disk in MB allocated to running capsules on this host. - paused_memory_mb: - type: integer - description: Total memory in MB allocated to paused capsules on this host. - paused_disk_mb: - type: integer - description: Total disk in MB allocated to paused capsules on this host. - - RefreshHostTokenRequest: - type: object - required: [refresh_token] - properties: - refresh_token: - type: string - description: Refresh token obtained from registration or a previous refresh. - - RefreshHostTokenResponse: - type: object - properties: - host: - $ref: "#/components/schemas/Host" - token: - type: string - description: New host JWT. Valid for 7 days. - refresh_token: - type: string - description: New refresh token. Valid for 60 days; old token is revoked. - - HostDeletePreview: - type: object - properties: - host: - $ref: "#/components/schemas/Host" - sandbox_ids: - type: array - items: - type: string - description: IDs of capsules that would be destroyed on force-delete. - - HostHasCapsulesError: - type: object - properties: - error: - type: object - properties: - code: - type: string - example: host_has_sandboxes - message: - type: string - sandbox_ids: - type: array - items: - type: string - description: IDs of active capsules blocking deletion. - - AddTagRequest: - type: object - required: [tag] - properties: - tag: - type: string - - UserSearchResult: - type: object - properties: - user_id: - type: string - email: - type: string - - Team: - type: object - properties: - id: - type: string - name: - type: string - slug: - type: string - description: Immutable 12-char hex slug (e.g. a1b2c3-d1e2f3) - created_at: - type: string - format: date-time - - TeamWithRole: - allOf: - - $ref: "#/components/schemas/Team" - - type: object - properties: - role: - type: string - enum: [owner, admin, member] - - TeamMember: - type: object - properties: - user_id: - type: string - email: - type: string - role: - type: string - enum: [owner, admin, member] - joined_at: - type: string - format: date-time - - TeamDetail: - type: object - properties: - team: - $ref: "#/components/schemas/Team" - members: - type: array - items: - $ref: "#/components/schemas/TeamMember" - - AdminTeam: - type: object - properties: - id: - type: string - name: - type: string - slug: - type: string - is_byoc: - type: boolean - created_at: - type: string - format: date-time - deleted_at: - type: string - format: date-time - nullable: true - member_count: - type: integer - owner_name: - type: string - owner_email: - type: string - active_sandbox_count: - type: integer - channel_count: - type: integer - running_vcpus: - type: integer - running_memory_mb: - type: integer - CapsuleMetrics: type: object properties: @@ -4244,12 +1600,18 @@ components: type: string range: type: string - enum: ["5m", "10m", "1h", "2h", "6h", "12h", "24h"] + enum: + - 5m + - 10m + - 1h + - 2h + - 6h + - 12h + - 24h points: type: array items: - $ref: "#/components/schemas/MetricPoint" - + $ref: '#/components/schemas/MetricPoint' MetricPoint: type: object properties: @@ -4259,153 +1621,15 @@ components: cpu_pct: type: number format: double - description: "CPU utilization percentage (0-100), normalized to vCPU count" + description: CPU utilization percentage (0-100), normalized to vCPU count mem_bytes: type: integer format: int64 - description: "Resident memory in bytes (VmRSS of Cloud Hypervisor process)" + description: Resident memory in bytes (VmRSS of Cloud Hypervisor process) disk_bytes: type: integer format: int64 - description: "Allocated disk bytes for the CoW sparse file" - - CreateChannelRequest: - type: object - required: [name, provider, config, events] - properties: - name: - type: string - description: Unique channel name within the team. - provider: - type: string - enum: [discord, slack, teams, googlechat, telegram, matrix, webhook] - config: - type: object - additionalProperties: - type: string - description: > - Provider-specific configuration fields. - Discord/Slack/Teams/Google Chat: {"webhook_url": "..."}. - Telegram: {"bot_token": "...", "chat_id": "..."}. - Matrix: {"homeserver_url": "...", "access_token": "...", "room_id": "..."}. - Webhook: {"url": "...", "secret": "..."} (secret is auto-generated if omitted). - events: - type: array - items: - type: string - enum: - - capsule.create - - capsule.pause - - capsule.resume - - capsule.destroy - - template.snapshot.create - - template.snapshot.delete - - host.up - - host.down - - TestChannelRequest: - type: object - required: [provider, config] - properties: - provider: - type: string - enum: [discord, slack, teams, googlechat, telegram, matrix, webhook] - config: - type: object - additionalProperties: - type: string - description: Provider-specific configuration fields (same as CreateChannelRequest.config). - - RotateConfigRequest: - type: object - required: [config] - properties: - config: - type: object - additionalProperties: - type: string - description: > - New provider configuration fields. Must include all required fields - for the channel's provider. Replaces the existing config entirely. - - UpdateChannelRequest: - type: object - required: [name, events] - properties: - name: - type: string - events: - type: array - items: - type: string - enum: - - capsule.create - - capsule.pause - - capsule.resume - - capsule.destroy - - template.snapshot.create - - template.snapshot.delete - - host.up - - host.down - - ChannelResponse: - type: object - properties: - id: - type: string - team_id: - type: string - name: - type: string - provider: - type: string - enum: [discord, slack, teams, googlechat, telegram, matrix, webhook] - events: - type: array - items: - type: string - created_at: - type: string - format: date-time - updated_at: - type: string - format: date-time - secret: - type: string - nullable: true - description: Webhook secret. Only returned on creation, never again. - - MeResponse: - type: object - properties: - name: - type: string - email: - type: string - format: email - has_password: - type: boolean - description: Whether the user has a password set (false for OAuth-only accounts) - providers: - type: array - items: - type: string - description: List of linked OAuth provider names (e.g. ["github"]) - - ChangePasswordRequest: - type: object - required: [new_password] - properties: - current_password: - type: string - description: Required when changing an existing password - new_password: - type: string - minLength: 8 - confirm_password: - type: string - description: Required when adding a password to an OAuth-only account (must match new_password) - + description: Allocated disk bytes for the CoW sparse file Error: type: object properties: @@ -4416,76 +1640,76 @@ components: type: string message: type: string - - AuditLogEntry: - type: object - properties: - id: {type: string} - actor_type: {type: string, enum: [user, api_key, host, system]} - actor_id: {type: string} - actor_name: {type: string} - resource_type: {type: string} - resource_id: {type: string} - action: {type: string} - scope: {type: string} - status: {type: string, enum: [success, failure]} - metadata: - type: object - additionalProperties: true - created_at: - type: string - format: date-time - SSEEvent: type: object - description: | - Wire format of one SSE message body. The event name (`event:` line) is + description: 'Wire format of one SSE message body. The event name (`event:` line) is + the `kind` and the JSON below is the `data:` line. + + ' properties: event: type: string enum: - - connected - - capsule.create - - capsule.pause - - capsule.resume - - capsule.destroy - - capsule.state.changed - - template.snapshot.create - - template.snapshot.delete - - host.up - - host.down + - connected + - capsule.create + - capsule.pause + - capsule.resume + - capsule.destroy + - capsule.state.changed + - template.snapshot.create + - template.snapshot.delete + - host.up + - host.down outcome: type: string - enum: [success, error] - description: | - Present for action events (capsule.* except state.changed, + enum: + - success + - error + description: 'Present for action events (capsule.* except state.changed, + template.snapshot.*). Absent for host.up/down, capsule.state.changed, + and the connected sentinel. + + ' resource: type: object properties: - id: {type: string} - type: {type: string} + id: + type: string + type: + type: string actor: type: object properties: - type: {type: string, enum: [user, api_key, system]} - id: {type: string} - name: {type: string} + type: + type: string + enum: + - user + - api_key + - system + id: + type: string + name: + type: string metadata: type: object - additionalProperties: {type: string} - description: | - Event-specific context. Examples: `reason` (ttl_expired, + additionalProperties: + type: string + description: 'Event-specific context. Examples: `reason` (ttl_expired, + host_failure, cleanup_after_create_error, orphaned), + `host_ip`, `from`/`to` (for capsule.state.changed). + + ' error: type: string description: Failure reason; only set when outcome=error. sandbox: allOf: - - $ref: "#/components/schemas/Capsule" + - $ref: '#/components/schemas/Capsule' nullable: true description: Populated for capsule.* events; null if DB lookup failed. timestamp: diff --git a/docs/reference.md b/docs/reference.md index 7c2d90c..4bfca2c 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -166,6 +166,131 @@ Reset the inactivity timer for a capsule. - `WrennNotFoundError` - If no capsule with the given ID exists. + + +#### stats + +```python +def stats(range: str | None = None) -> CapsuleStats +``` + +Get aggregate capsule usage stats for the authenticated team. + +**Arguments**: + +- `range` _str | None_ - Time window. One of ``5m``, ``1h``, ``6h``, + ``24h``, ``30d``. Defaults to ``1h`` server-side. + + +**Returns**: + +- `CapsuleStats` - Current running counts plus 30-day peaks and a + chart-ready time series. + + Example:: + + stats = wrenn.capsules.stats(range="24h") + print(stats.current.running_count, stats.peaks.vcpus) + + + +#### usage + +```python +def usage(from_: str | date | None = None, + to: str | date | None = None) -> UsageResponse +``` + +Get per-day CPU and RAM usage for the team. + +**Arguments**: + +- `from_` _str | date | None_ - Start date (``YYYY-MM-DD`` string or + ``date``). Defaults to 30 days ago server-side. +- `to` _str | date | None_ - End date. Defaults to today. + + +**Returns**: + +- `UsageResponse` - Daily ``cpu_minutes`` / ``ram_mb_minutes`` points. + + Example:: + + from datetime import date, timedelta + + today = date.today() + usage = wrenn.capsules.usage(from_=today - timedelta(days=7), to=today) + for point in usage.points: + print(point.date, point.cpu_minutes, point.ram_mb_minutes) + + + +#### metrics + +```python +def metrics(id: str, range: str | None = None) -> CapsuleMetrics +``` + +Get time-series CPU, memory, and disk metrics for a capsule. + +**Arguments**: + +- `id` _str_ - Capsule ID. +- `range` _str | None_ - One of ``10m`` (500ms samples), + ``2h`` (30s averages), ``24h`` (5-minute averages). Defaults + to ``10m`` server-side. + + +**Returns**: + +- `CapsuleMetrics` - Sampled :class:`MetricPoint` series. + + +**Raises**: + +- `WrennNotFoundError` - If the capsule does not exist or has been + destroyed. + + Example:: + + m = wrenn.capsules.metrics("sb-abc123", range="2h") + for point in m.points: + print(point.timestamp_unix, point.cpu_pct, point.mem_bytes) + + + +## EventsResource Objects + +```python +class EventsResource() +``` + +Sync server-sent event stream of capsule/template/host lifecycle events. + + + +#### stream + +```python +def stream() -> Iterator[SSEEvent] +``` + +Stream lifecycle events for the team in real time. + +The connection is held open by the server; iterate the result to +receive :class:`SSEEvent` payloads as they arrive. Close the +iterator (or break out of the loop) to disconnect. + +**Yields**: + +- `SSEEvent` - One event per server frame. + + Example:: + + with WrennClient() as wrenn: + for ev in wrenn.events.stream(): + print(ev.event, ev.resource) + ## AsyncCapsulesResource Objects @@ -326,6 +451,129 @@ Reset the inactivity timer for a capsule. - `WrennNotFoundError` - If no capsule with the given ID exists. + + +#### stats + +```python +async def stats(range: str | None = None) -> CapsuleStats +``` + +Get aggregate capsule usage stats for the authenticated team. + +**Arguments**: + +- `range` _str | None_ - Time window. One of ``5m``, ``1h``, ``6h``, + ``24h``, ``30d``. Defaults to ``1h`` server-side. + + +**Returns**: + +- `CapsuleStats` - Current running counts plus 30-day peaks and a + chart-ready time series. + + Example:: + + stats = await wrenn.capsules.stats(range="24h") + print(stats.current.running_count, stats.peaks.vcpus) + + + +#### usage + +```python +async def usage(from_: str | date | None = None, + to: str | date | None = None) -> UsageResponse +``` + +Get per-day CPU and RAM usage for the team. + +**Arguments**: + +- `from_` _str | date | None_ - Start date (``YYYY-MM-DD`` string or + ``date``). Defaults to 30 days ago server-side. +- `to` _str | date | None_ - End date. Defaults to today. + + +**Returns**: + +- `UsageResponse` - Daily ``cpu_minutes`` / ``ram_mb_minutes`` points. + + Example:: + + from datetime import date, timedelta + + today = date.today() + usage = await wrenn.capsules.usage( + from_=today - timedelta(days=7), to=today + ) + for point in usage.points: + print(point.date, point.cpu_minutes, point.ram_mb_minutes) + + + +#### metrics + +```python +async def metrics(id: str, range: str | None = None) -> CapsuleMetrics +``` + +Get time-series CPU, memory, and disk metrics for a capsule. + +**Arguments**: + +- `id` _str_ - Capsule ID. +- `range` _str | None_ - One of ``10m`` (500ms samples), + ``2h`` (30s averages), ``24h`` (5-minute averages). Defaults + to ``10m`` server-side. + + +**Returns**: + +- `CapsuleMetrics` - Sampled :class:`MetricPoint` series. + + +**Raises**: + +- `WrennNotFoundError` - If the capsule does not exist or has been + destroyed. + + Example:: + + m = await wrenn.capsules.metrics("sb-abc123", range="2h") + for point in m.points: + print(point.timestamp_unix, point.cpu_pct, point.mem_bytes) + + + +## AsyncEventsResource Objects + +```python +class AsyncEventsResource() +``` + +Async server-sent event stream of capsule/template/host lifecycle events. + + + +#### stream + +```python +async def stream() -> AsyncIterator[SSEEvent] +``` + +Stream lifecycle events for the team in real time. + +**Yields**: + +- `SSEEvent` - One event per server frame. + + Example:: + + async with AsyncWrennClient() as wrenn: + async for ev in wrenn.events.stream(): + print(ev.event, ev.resource) + ## SnapshotsResource Objects @@ -484,7 +732,11 @@ class WrennClient() Synchronous client for the Wrenn API. -Authenticates with an API key. +Authenticates with an API key. Exposes three resources: + +- :attr:`capsules` — capsule lifecycle, stats, usage, metrics +- :attr:`snapshots` — template snapshot management +- :attr:`events` — server-sent lifecycle event stream **Arguments**: @@ -496,6 +748,15 @@ Authenticates with an API key. is the default ``app.wrenn.dev`` host, else the ``base_url`` host. - `timeout` - HTTP timeout. Accepts ``httpx.Timeout``, a float (seconds), or ``None`` for the default (30s read/write/pool, 10s connect). + + Example:: + + from wrenn import WrennClient + + with WrennClient() as wrenn: # reads WRENN_API_KEY + capsule = wrenn.capsules.create(template="minimal-ubuntu") + print(capsule.id, capsule.status) + wrenn.capsules.destroy(capsule.id) @@ -528,7 +789,8 @@ class AsyncWrennClient() Asynchronous client for the Wrenn API. -Authenticates with an API key. +Authenticates with an API key. Mirrors :class:`WrennClient` with +``await``-able methods on every resource. **Arguments**: @@ -540,6 +802,16 @@ Authenticates with an API key. is the default ``app.wrenn.dev`` host, else the ``base_url`` host. - `timeout` - HTTP timeout. Accepts ``httpx.Timeout``, a float (seconds), or ``None`` for the default (30s read/write/pool, 10s connect). + + Example:: + + from wrenn import AsyncWrennClient + + async with AsyncWrennClient() as wrenn: + capsule = await wrenn.capsules.create(template="minimal-ubuntu") + async for event in wrenn.events.stream(): + if event.resource and event.resource.id == capsule.id: + break @@ -1936,18 +2208,6 @@ Send SIGKILL to the PTY process. # wrenn.models.\_generated - - -## SessionResponse Objects - -```python -class SessionResponse(BaseModel) -``` - -Returned by login, activate, and switch-team. The actual auth credential -is the wrenn_sid cookie set on the response. The body carries identity -data the SPA needs to bootstrap. - ## Peaks Objects @@ -1978,16 +2238,6 @@ class Encoding(StrEnum) Output encoding. "base64" when stdout/stderr contain binary data. - - -## Type2 Objects - -```python -class Type2(StrEnum) -``` - -Host type. Regular hosts are shared; BYOC hosts belong to a team. - ## Outcome Objects diff --git a/pyproject.toml b/pyproject.toml index b67c7a8..e32bd3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "wrenn" -version = "0.2.0" +version = "0.3.0" description = "Python SDK for Wrenn" readme = "README.md" license = "MIT" @@ -23,7 +23,6 @@ classifiers = [ ] dependencies = [ "certifi>=2026.2.25", - "email-validator>=2.3.0", "httpx>=0.28.1", "httpx-ws>=0.9.0", "pydantic>=2.12.5", @@ -41,6 +40,7 @@ dev = [ "pydoc-markdown>=4.8.2", "pytest>=9.0.3", "pytest-asyncio>=1.3.0", + "pyyaml>=6.0.3", "respx>=0.23.1", "ruff>=0.15.10", ] diff --git a/scripts/prune_openapi.py b/scripts/prune_openapi.py new file mode 100644 index 0000000..ac82997 --- /dev/null +++ b/scripts/prune_openapi.py @@ -0,0 +1,179 @@ +"""Prune the upstream Wrenn OpenAPI spec down to the SDK's surface. + +The upstream spec at ``wrennhq/wrenn/internal/api/openapi.yaml`` covers +the whole control plane: account/team/host/channel/admin routes that +require an interactive ``wrenn_sid`` cookie. The Python SDK only ever +authenticates with an API key, so those routes are dead surface area +here. This script keeps only paths that list ``apiKeyAuth`` (and the +schemas they transitively reach) and rewrites the spec in place. + +Run via ``make generate`` (curl ➜ prune ➜ datamodel-codegen) or +``uv run python scripts/prune_openapi.py [path]`` directly. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +import yaml + +KEEP_PATHS: set[str] = { + "/v1/capsules", + "/v1/capsules/stats", + "/v1/capsules/usage", + "/v1/capsules/{id}", + "/v1/capsules/{id}/exec", + "/v1/capsules/{id}/exec/stream", + "/v1/capsules/{id}/processes", + "/v1/capsules/{id}/processes/{selector}", + "/v1/capsules/{id}/processes/{selector}/stream", + "/v1/capsules/{id}/ping", + "/v1/capsules/{id}/metrics", + "/v1/capsules/{id}/pause", + "/v1/capsules/{id}/resume", + "/v1/capsules/{id}/files/write", + "/v1/capsules/{id}/files/read", + "/v1/capsules/{id}/files/list", + "/v1/capsules/{id}/files/mkdir", + "/v1/capsules/{id}/files/remove", + "/v1/capsules/{id}/files/stream/write", + "/v1/capsules/{id}/files/stream/read", + "/v1/capsules/{id}/pty", + "/v1/snapshots", + "/v1/snapshots/{name}", + "/v1/events/stream", +} + +KEEP_SECURITY_SCHEMES: set[str] = {"apiKeyAuth"} + +REF_RE = re.compile(r"#/components/(schemas|responses)/([A-Za-z0-9_]+)") + + +def _walk_refs(node: object, schemas: dict, responses: dict, seen: set[str]) -> None: + """Depth-first walk collecting ``schemas:Name`` / ``responses:Name`` refs.""" + if isinstance(node, dict): + for k, v in node.items(): + if k == "$ref" and isinstance(v, str): + m = REF_RE.search(v) + if m: + kind, name = m.group(1), m.group(2) + key = f"{kind}:{name}" + if key in seen: + continue + seen.add(key) + target = (schemas if kind == "schemas" else responses).get(name) + if target is not None: + _walk_refs(target, schemas, responses, seen) + else: + _walk_refs(v, schemas, responses, seen) + elif isinstance(node, list): + for item in node: + _walk_refs(item, schemas, responses, seen) + + +def _filter_security(operation: dict) -> None: + sec = operation.get("security") + if not isinstance(sec, list): + return + kept = [ + entry + for entry in sec + if isinstance(entry, dict) + and any(name in KEEP_SECURITY_SCHEMES for name in entry) + ] + if kept: + operation["security"] = kept + else: + operation.pop("security", None) + + +def prune(spec: dict) -> dict: + paths = spec.get("paths", {}) + components = spec.get("components", {}) + schemas = components.get("schemas", {}) or {} + responses = components.get("responses", {}) or {} + + missing = KEEP_PATHS - set(paths) + if missing: + raise SystemExit( + "prune_openapi: upstream spec is missing expected paths: " + + ", ".join(sorted(missing)) + ) + + kept_paths: dict[str, dict] = {} + for path, item in paths.items(): + if path not in KEEP_PATHS: + continue + if not isinstance(item, dict): + kept_paths[path] = item + continue + for method, op in list(item.items()): + if isinstance(op, dict): + _filter_security(op) + if "security" in op and not op["security"]: + del item[method] + kept_paths[path] = item + + seen: set[str] = set() + _walk_refs(kept_paths, schemas, responses, seen) + + # Walk again to close over inter-schema refs we just pulled in. + while True: + before = len(seen) + for key in list(seen): + kind, name = key.split(":", 1) + target = (schemas if kind == "schemas" else responses).get(name) + if target is not None: + _walk_refs(target, schemas, responses, seen) + if len(seen) == before: + break + + kept_schemas = {n: schemas[n] for n in schemas if f"schemas:{n}" in seen} + kept_responses = {n: responses[n] for n in responses if f"responses:{n}" in seen} + + sec_schemes = components.get("securitySchemes", {}) or {} + kept_sec_schemes = { + n: sec_schemes[n] for n in sec_schemes if n in KEEP_SECURITY_SCHEMES + } + + new_components: dict = {} + if kept_responses: + new_components["responses"] = kept_responses + if kept_sec_schemes: + new_components["securitySchemes"] = kept_sec_schemes + if kept_schemas: + new_components["schemas"] = kept_schemas + + spec["paths"] = kept_paths + spec["components"] = new_components + + top_security = spec.get("security") + if isinstance(top_security, list): + spec["security"] = [ + e + for e in top_security + if isinstance(e, dict) and any(name in KEEP_SECURITY_SCHEMES for name in e) + ] + + return spec + + +def main(argv: list[str]) -> int: + target = Path(argv[1]) if len(argv) > 1 else Path("api/openapi.yaml") + spec = yaml.safe_load(target.read_text()) + pruned = prune(spec) + target.write_text( + yaml.safe_dump(pruned, sort_keys=False, width=120, allow_unicode=True) + ) + print( + f"prune_openapi: kept {len(pruned['paths'])} paths, " + f"{len(pruned['components'].get('schemas', {}))} schemas in {target}", + file=sys.stderr, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/src/wrenn/__init__.py b/src/wrenn/__init__.py index 0c4cb64..1541454 100644 --- a/src/wrenn/__init__.py +++ b/src/wrenn/__init__.py @@ -37,7 +37,7 @@ from wrenn.exceptions import ( from wrenn.models import FileEntry from wrenn.pty import AsyncPtySession, PtyEvent, PtyEventType, PtySession -__version__ = "0.1.4" +__version__ = "0.3.0" __all__ = [ "__version__", diff --git a/src/wrenn/client.py b/src/wrenn/client.py index 58ef09b..ac9770b 100644 --- a/src/wrenn/client.py +++ b/src/wrenn/client.py @@ -3,6 +3,8 @@ from __future__ import annotations import asyncio import os import time +from collections.abc import AsyncIterator, Iterator +from datetime import date import httpx @@ -13,10 +15,14 @@ from wrenn._config import ( ENV_BASE_URL, ENV_PROXY_DOMAIN, ) -from wrenn.exceptions import handle_response +from wrenn.exceptions import _raise_for_status, handle_response from wrenn.models import ( + CapsuleMetrics, + CapsuleStats, + SSEEvent, Template, + UsageResponse, ) from wrenn.models import ( Capsule as CapsuleModel, @@ -151,6 +157,46 @@ def _snapshot_list_params(type: str | None) -> dict: return params +def _date_param(value: str | date | None) -> str | None: + if value is None: + return None + if isinstance(value, date): + return value.isoformat() + return value + + +def _usage_params(from_: str | date | None, to: str | date | None) -> dict: + params: dict = {} + if (v := _date_param(from_)) is not None: + params["from"] = v + if (v := _date_param(to)) is not None: + params["to"] = v + return params + + +def _range_params(range: str | None) -> dict: + return {"range": range} if range is not None else {} + + +def _iter_sse_events(lines: Iterator[str]) -> Iterator[SSEEvent]: + """Parse SSE ``data:`` frames into :class:`SSEEvent` objects. + + Ignores ``event:`` names and ``:keepalive`` comments — the payload's + own ``event`` field carries the kind. + """ + data_lines: list[str] = [] + for raw in lines: + if raw == "": + if data_lines: + yield SSEEvent.model_validate_json("\n".join(data_lines)) + data_lines = [] + continue + if raw.startswith(":"): + continue + if raw.startswith("data:"): + data_lines.append(raw[5:].lstrip()) + + class CapsulesResource: """Sync capsule control-plane operations.""" @@ -260,6 +306,104 @@ class CapsulesResource: resp = self._http.post(f"/v1/capsules/{id}/ping") handle_response(resp) + def stats(self, range: str | None = None) -> CapsuleStats: + """Get aggregate capsule usage stats for the authenticated team. + + Args: + range (str | None): Time window. One of ``5m``, ``1h``, ``6h``, + ``24h``, ``30d``. Defaults to ``1h`` server-side. + + Returns: + CapsuleStats: Current running counts plus 30-day peaks and a + chart-ready time series. + + Example:: + + stats = wrenn.capsules.stats(range="24h") + print(stats.current.running_count, stats.peaks.vcpus) + """ + resp = self._http.get("/v1/capsules/stats", params=_range_params(range)) + return CapsuleStats.model_validate(handle_response(resp)) + + def usage( + self, + from_: str | date | None = None, + to: str | date | None = None, + ) -> UsageResponse: + """Get per-day CPU and RAM usage for the team. + + Args: + from_ (str | date | None): Start date (``YYYY-MM-DD`` string or + ``date``). Defaults to 30 days ago server-side. + to (str | date | None): End date. Defaults to today. + + Returns: + UsageResponse: Daily ``cpu_minutes`` / ``ram_mb_minutes`` points. + + Example:: + + from datetime import date, timedelta + + today = date.today() + usage = wrenn.capsules.usage(from_=today - timedelta(days=7), to=today) + for point in usage.points: + print(point.date, point.cpu_minutes, point.ram_mb_minutes) + """ + resp = self._http.get("/v1/capsules/usage", params=_usage_params(from_, to)) + return UsageResponse.model_validate(handle_response(resp)) + + def metrics(self, id: str, range: str | None = None) -> CapsuleMetrics: + """Get time-series CPU, memory, and disk metrics for a capsule. + + Args: + id (str): Capsule ID. + range (str | None): One of ``10m`` (500ms samples), + ``2h`` (30s averages), ``24h`` (5-minute averages). Defaults + to ``10m`` server-side. + + Returns: + CapsuleMetrics: Sampled :class:`MetricPoint` series. + + Raises: + WrennNotFoundError: If the capsule does not exist or has been + destroyed. + + Example:: + + m = wrenn.capsules.metrics("sb-abc123", range="2h") + for point in m.points: + print(point.timestamp_unix, point.cpu_pct, point.mem_bytes) + """ + resp = self._http.get(f"/v1/capsules/{id}/metrics", params=_range_params(range)) + return CapsuleMetrics.model_validate(handle_response(resp)) + + +class EventsResource: + """Sync server-sent event stream of capsule/template/host lifecycle events.""" + + def __init__(self, http: httpx.Client) -> None: + self._http = http + + def stream(self) -> Iterator[SSEEvent]: + """Stream lifecycle events for the team in real time. + + The connection is held open by the server; iterate the result to + receive :class:`SSEEvent` payloads as they arrive. Close the + iterator (or break out of the loop) to disconnect. + + Yields: + SSEEvent: One event per server frame. + + Example:: + + with WrennClient() as wrenn: + for ev in wrenn.events.stream(): + print(ev.event, ev.resource) + """ + with self._http.stream("GET", "/v1/events/stream", timeout=None) as resp: + _raise_for_status(resp) + yield from _iter_sse_events(resp.iter_lines()) + class AsyncCapsulesResource: """Async capsule control-plane operations.""" @@ -370,6 +514,116 @@ class AsyncCapsulesResource: resp = await self._http.post(f"/v1/capsules/{id}/ping") handle_response(resp) + async def stats(self, range: str | None = None) -> CapsuleStats: + """Get aggregate capsule usage stats for the authenticated team. + + Args: + range (str | None): Time window. One of ``5m``, ``1h``, ``6h``, + ``24h``, ``30d``. Defaults to ``1h`` server-side. + + Returns: + CapsuleStats: Current running counts plus 30-day peaks and a + chart-ready time series. + + Example:: + + stats = await wrenn.capsules.stats(range="24h") + print(stats.current.running_count, stats.peaks.vcpus) + """ + resp = await self._http.get("/v1/capsules/stats", params=_range_params(range)) + return CapsuleStats.model_validate(handle_response(resp)) + + async def usage( + self, + from_: str | date | None = None, + to: str | date | None = None, + ) -> UsageResponse: + """Get per-day CPU and RAM usage for the team. + + Args: + from_ (str | date | None): Start date (``YYYY-MM-DD`` string or + ``date``). Defaults to 30 days ago server-side. + to (str | date | None): End date. Defaults to today. + + Returns: + UsageResponse: Daily ``cpu_minutes`` / ``ram_mb_minutes`` points. + + Example:: + + from datetime import date, timedelta + + today = date.today() + usage = await wrenn.capsules.usage( + from_=today - timedelta(days=7), to=today + ) + for point in usage.points: + print(point.date, point.cpu_minutes, point.ram_mb_minutes) + """ + resp = await self._http.get( + "/v1/capsules/usage", params=_usage_params(from_, to) + ) + return UsageResponse.model_validate(handle_response(resp)) + + async def metrics(self, id: str, range: str | None = None) -> CapsuleMetrics: + """Get time-series CPU, memory, and disk metrics for a capsule. + + Args: + id (str): Capsule ID. + range (str | None): One of ``10m`` (500ms samples), + ``2h`` (30s averages), ``24h`` (5-minute averages). Defaults + to ``10m`` server-side. + + Returns: + CapsuleMetrics: Sampled :class:`MetricPoint` series. + + Raises: + WrennNotFoundError: If the capsule does not exist or has been + destroyed. + + Example:: + + m = await wrenn.capsules.metrics("sb-abc123", range="2h") + for point in m.points: + print(point.timestamp_unix, point.cpu_pct, point.mem_bytes) + """ + resp = await self._http.get( + f"/v1/capsules/{id}/metrics", params=_range_params(range) + ) + return CapsuleMetrics.model_validate(handle_response(resp)) + + +class AsyncEventsResource: + """Async server-sent event stream of capsule/template/host lifecycle events.""" + + def __init__(self, http: httpx.AsyncClient) -> None: + self._http = http + + async def stream(self) -> AsyncIterator[SSEEvent]: + """Stream lifecycle events for the team in real time. + + Yields: + SSEEvent: One event per server frame. + + Example:: + + async with AsyncWrennClient() as wrenn: + async for ev in wrenn.events.stream(): + print(ev.event, ev.resource) + """ + async with self._http.stream("GET", "/v1/events/stream", timeout=None) as resp: + _raise_for_status(resp) + data_lines: list[str] = [] + async for raw in resp.aiter_lines(): + if raw == "": + if data_lines: + yield SSEEvent.model_validate_json("\n".join(data_lines)) + data_lines = [] + continue + if raw.startswith(":"): + continue + if raw.startswith("data:"): + data_lines.append(raw[5:].lstrip()) + class SnapshotsResource: """Sync snapshot operations.""" @@ -486,7 +740,11 @@ class AsyncSnapshotsResource: class WrennClient: """Synchronous client for the Wrenn API. - Authenticates with an API key. + Authenticates with an API key. Exposes three resources: + + - :attr:`capsules` — capsule lifecycle, stats, usage, metrics + - :attr:`snapshots` — template snapshot management + - :attr:`events` — server-sent lifecycle event stream Args: api_key: API key (``wrn_...``). Falls back to ``WRENN_API_KEY`` env var. @@ -497,6 +755,15 @@ class WrennClient: is the default ``app.wrenn.dev`` host, else the ``base_url`` host. timeout: HTTP timeout. Accepts ``httpx.Timeout``, a float (seconds), or ``None`` for the default (30s read/write/pool, 10s connect). + + Example:: + + from wrenn import WrennClient + + with WrennClient() as wrenn: # reads WRENN_API_KEY + capsule = wrenn.capsules.create(template="minimal-ubuntu") + print(capsule.id, capsule.status) + wrenn.capsules.destroy(capsule.id) """ def __init__( @@ -517,6 +784,7 @@ class WrennClient: self.capsules = CapsulesResource(self._http) self.snapshots = SnapshotsResource(self._http) + self.events = EventsResource(self._http) @property def http(self) -> httpx.Client: @@ -542,7 +810,8 @@ class WrennClient: class AsyncWrennClient: """Asynchronous client for the Wrenn API. - Authenticates with an API key. + Authenticates with an API key. Mirrors :class:`WrennClient` with + ``await``-able methods on every resource. Args: api_key: API key (``wrn_...``). Falls back to ``WRENN_API_KEY`` env var. @@ -553,6 +822,16 @@ class AsyncWrennClient: is the default ``app.wrenn.dev`` host, else the ``base_url`` host. timeout: HTTP timeout. Accepts ``httpx.Timeout``, a float (seconds), or ``None`` for the default (30s read/write/pool, 10s connect). + + Example:: + + from wrenn import AsyncWrennClient + + async with AsyncWrennClient() as wrenn: + capsule = await wrenn.capsules.create(template="minimal-ubuntu") + async for event in wrenn.events.stream(): + if event.resource and event.resource.id == capsule.id: + break """ def __init__( @@ -573,6 +852,7 @@ class AsyncWrennClient: self.capsules = AsyncCapsulesResource(self._http) self.snapshots = AsyncSnapshotsResource(self._http) + self.events = AsyncEventsResource(self._http) @property def http(self) -> httpx.AsyncClient: diff --git a/src/wrenn/models/__init__.py b/src/wrenn/models/__init__.py index 49c99b1..d7e8351 100644 --- a/src/wrenn/models/__init__.py +++ b/src/wrenn/models/__init__.py @@ -1,17 +1,31 @@ from wrenn.models._generated import ( + Actor, Capsule, + CapsuleMetrics, + CapsuleStats, FileEntry, ListDirResponse, MakeDirResponse, + MetricPoint, + Resource, + SSEEvent, Status, Template, + UsageResponse, ) __all__ = [ + "Actor", "Capsule", + "CapsuleMetrics", + "CapsuleStats", "FileEntry", "ListDirResponse", "MakeDirResponse", + "MetricPoint", + "Resource", + "SSEEvent", "Status", "Template", + "UsageResponse", ] diff --git a/src/wrenn/models/_generated.py b/src/wrenn/models/_generated.py index 3104a87..2eeb5a3 100644 --- a/src/wrenn/models/_generated.py +++ b/src/wrenn/models/_generated.py @@ -1,13 +1,91 @@ # generated by datamodel-codegen: # filename: openapi.yaml -# timestamp: 2026-05-23T11:20:02+00:00 +# timestamp: 2026-06-22T19:04:03+00:00 from __future__ import annotations -from pydantic import AwareDatetime, BaseModel, Field from typing import Annotated +from pydantic import AwareDatetime, BaseModel, Field +from datetime import date as date_aliased from enum import StrEnum +class CreateCapsuleRequest(BaseModel): + template: str | None = "minimal-ubuntu" + vcpus: int | None = 1 + memory_mb: int | None = 512 + timeout_sec: Annotated[ + int | None, + Field( + description="Auto-pause TTL in seconds. The capsule is automatically paused 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).\n", + ge=0, + ), + ] = 0 + metadata: Annotated[ + dict[str, str] | None, + Field( + 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.\n" + ), + ] = None + + +class Point(BaseModel): + date: date_aliased | None = None + cpu_minutes: float | None = None + ram_mb_minutes: float | None = None + + +class UsageResponse(BaseModel): + from_: Annotated[date_aliased | None, Field(alias="from")] = None + to: date_aliased | None = None + points: list[Point] | None = None + + +class Range(StrEnum): + field_5m = "5m" + field_1h = "1h" + field_6h = "6h" + field_24h = "24h" + field_30d = "30d" + + +class Current(BaseModel): + running_count: int | None = None + vcpus_reserved: int | None = None + memory_mb_reserved: int | None = None + + +class Peaks(BaseModel): + """ + Maximum values over the last 30 days. + """ + + running_count: int | None = None + vcpus: int | None = None + memory_mb: int | None = None + + +class Series(BaseModel): + """ + Parallel arrays for chart rendering. + """ + + labels: list[AwareDatetime] | None = None + running: list[int] | None = None + vcpus: list[int] | None = None + memory_mb: list[int] | None = None + + +class CapsuleStats(BaseModel): + range: Range | None = None + current: Current | None = None + peaks: Annotated[ + Peaks | None, Field(description="Maximum values over the last 30 days.") + ] = None + series: Annotated[ + Series | None, Field(description="Parallel arrays for chart rendering.") + ] = None + + class Status(StrEnum): pending = "pending" starting = "starting" @@ -37,7 +115,7 @@ class Capsule(BaseModel): metadata: Annotated[ dict[str, str] | None, Field( - description="Free-form key/value labels attached at create-time. Also carries\nagent-side version info (kernel_version, vmm_version,\nagent_version, envd_version) when running.\n" + description="User-supplied key/value labels attached at create-time. Once the\ncapsule is running this also carries agent-side system version info\n(kernel_version, vmm_version, agent_version, envd_version); those\nreserved keys are owned by the system and cannot be set by users.\n" ), ] = None disk_size_mb: Annotated[ @@ -51,6 +129,16 @@ class Capsule(BaseModel): ] = None +class CreateSnapshotRequest(BaseModel): + sandbox_id: Annotated[ + str, Field(description="ID of the running capsule to snapshot.") + ] + name: Annotated[ + str | None, + Field(description="Name for the snapshot template. Auto-generated if omitted."), + ] = None + + class Type(StrEnum): base = "base" snapshot = "snapshot" @@ -78,7 +166,96 @@ class Template(BaseModel): metadata: dict[str, str] | None = None -class Type2(StrEnum): +class ExecRequest(BaseModel): + cmd: str + args: list[str] | None = None + timeout_sec: Annotated[ + int | None, + Field(description="Timeout in seconds (foreground exec only, default 30)"), + ] = 30 + background: Annotated[ + bool | None, + Field( + description="If true, starts the process in the background and returns immediately with a PID and tag (HTTP 202)" + ), + ] = False + tag: Annotated[ + str | None, + Field( + description="Optional user-chosen tag for the background process. Auto-generated if omitted. Only used when background is true." + ), + ] = None + envs: Annotated[ + dict[str, str] | None, + Field( + description="Environment variables for the process (applies to both foreground and background exec)" + ), + ] = None + cwd: Annotated[ + str | None, + Field( + description="Working directory for the process (applies to both foreground and background exec)" + ), + ] = None + + +class BackgroundExecResponse(BaseModel): + sandbox_id: str | None = None + cmd: str | None = None + pid: int | None = None + tag: str | None = None + + +class ProcessEntry(BaseModel): + pid: int | None = None + tag: str | None = None + cmd: str | None = None + args: list[str] | None = None + + +class ProcessListResponse(BaseModel): + processes: list[ProcessEntry] | None = None + + +class Encoding(StrEnum): + """ + Output encoding. "base64" when stdout/stderr contain binary data. + """ + + utf_8 = "utf-8" + base64 = "base64" + + +class ExecResponse(BaseModel): + sandbox_id: str | None = None + cmd: str | None = None + stdout: str | None = None + stderr: str | None = None + exit_code: int | None = None + duration_ms: int | None = None + encoding: Annotated[ + Encoding | None, + Field( + description='Output encoding. "base64" when stdout/stderr contain binary data.' + ), + ] = None + + +class ReadFileRequest(BaseModel): + path: Annotated[str, Field(description="Absolute file path inside the capsule")] + + +class ListDirRequest(BaseModel): + path: Annotated[str, Field(description="Directory path inside the capsule")] + depth: Annotated[ + int | None, + Field( + description="Recursion depth (0 = non-recursive, 1 = immediate children)" + ), + ] = 1 + + +class Type1(StrEnum): file = "file" directory = "directory" symlink = "symlink" @@ -87,7 +264,7 @@ class Type2(StrEnum): class FileEntry(BaseModel): name: str | None = None path: str | None = None - type: Type2 | None = None + type: Type1 | None = None size: int | None = None mode: int | None = None permissions: Annotated[ @@ -101,9 +278,137 @@ class FileEntry(BaseModel): symlink_target: str | None = None +class MakeDirRequest(BaseModel): + path: Annotated[ + str, Field(description="Directory path to create inside the capsule") + ] + + class MakeDirResponse(BaseModel): entry: FileEntry | None = None +class RemoveRequest(BaseModel): + path: Annotated[str, Field(description="Path to remove inside the capsule")] + + +class Range1(StrEnum): + field_5m = "5m" + field_10m = "10m" + field_1h = "1h" + field_2h = "2h" + field_6h = "6h" + field_12h = "12h" + field_24h = "24h" + + +class MetricPoint(BaseModel): + timestamp_unix: int | None = None + cpu_pct: Annotated[ + float | None, + Field( + description="CPU utilization percentage (0-100), normalized to vCPU count" + ), + ] = None + mem_bytes: Annotated[ + int | None, + Field( + description="Resident memory in bytes (VmRSS of Cloud Hypervisor process)" + ), + ] = None + disk_bytes: Annotated[ + int | None, Field(description="Allocated disk bytes for the CoW sparse file") + ] = None + + +class Error1(BaseModel): + code: str | None = None + message: str | None = None + + +class Error(BaseModel): + error: Error1 | None = None + + +class Event(StrEnum): + connected = "connected" + capsule_create = "capsule.create" + capsule_pause = "capsule.pause" + capsule_resume = "capsule.resume" + capsule_destroy = "capsule.destroy" + capsule_state_changed = "capsule.state.changed" + template_snapshot_create = "template.snapshot.create" + template_snapshot_delete = "template.snapshot.delete" + host_up = "host.up" + host_down = "host.down" + + +class Outcome(StrEnum): + """ + Present for action events (capsule.* except state.changed, + template.snapshot.*). Absent for host.up/down, capsule.state.changed, + and the connected sentinel. + + """ + + success = "success" + error = "error" + + +class Resource(BaseModel): + id: str | None = None + type: str | None = None + + +class Type2(StrEnum): + user = "user" + api_key = "api_key" + system = "system" + + +class Actor(BaseModel): + type: Type2 | None = None + id: str | None = None + name: str | None = None + + +class SSEEvent(BaseModel): + """ + Wire format of one SSE message body. The event name (`event:` line) is + the `kind` and the JSON below is the `data:` line. + + """ + + event: Event | None = None + outcome: Annotated[ + Outcome | None, + Field( + description="Present for action events (capsule.* except state.changed,\ntemplate.snapshot.*). Absent for host.up/down, capsule.state.changed,\nand the connected sentinel.\n" + ), + ] = None + resource: Resource | None = None + actor: Actor | None = None + metadata: Annotated[ + dict[str, str] | None, + Field( + description="Event-specific context. Examples: `reason` (ttl_expired,\nhost_failure, cleanup_after_create_error, orphaned),\n`host_ip`, `from`/`to` (for capsule.state.changed).\n" + ), + ] = None + error: Annotated[ + str | None, Field(description="Failure reason; only set when outcome=error.") + ] = None + sandbox: Annotated[ + Capsule | None, + Field(description="Populated for capsule.* events; null if DB lookup failed."), + ] = None + timestamp: AwareDatetime | None = None + + class ListDirResponse(BaseModel): entries: list[FileEntry] | None = None + + +class CapsuleMetrics(BaseModel): + sandbox_id: str | None = None + range: Range1 | None = None + points: list[MetricPoint] | None = None diff --git a/tests/test_client.py b/tests/test_client.py index 3bc31ed..78cc2bf 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -103,6 +103,73 @@ class TestCapsules: client.capsules.ping("sb-1") assert route.called + @respx.mock + def test_stats(self, client): + route = respx.get(f"{BASE}/v1/capsules/stats").respond( + 200, + json={ + "range": "6h", + "current": {"running_count": 2, "vcpus_reserved": 4}, + "peaks": {"running_count": 9}, + "series": {"running": [1, 2, 2]}, + }, + ) + stats = client.capsules.stats(range="6h") + assert "range=6h" in str(route.calls[0].request.url) + assert stats.current and stats.current.running_count == 2 + assert stats.peaks and stats.peaks.running_count == 9 + + @respx.mock + def test_usage_passes_date_params(self, client): + import datetime as _dt + + route = respx.get(f"{BASE}/v1/capsules/usage").respond( + 200, json={"from": "2026-06-01", "to": "2026-06-22", "points": []} + ) + client.capsules.usage(from_=_dt.date(2026, 6, 1), to="2026-06-22") + url = str(route.calls[0].request.url) + assert "from=2026-06-01" in url + assert "to=2026-06-22" in url + + @respx.mock + def test_metrics(self, client): + respx.get(f"{BASE}/v1/capsules/sb-1/metrics").respond( + 200, + json={ + "sandbox_id": "sb-1", + "range": "10m", + "points": [{"timestamp_unix": 1, "cpu_pct": 12.5, "mem_bytes": 4096}], + }, + ) + m = client.capsules.metrics("sb-1") + assert m.sandbox_id == "sb-1" + assert m.points and m.points[0].cpu_pct == 12.5 + + +class TestEvents: + @respx.mock + def test_stream_parses_sse_frames(self, client): + body = ( + ": keepalive\n" + "event: capsule.create\n" + 'data: {"event":"capsule.create","resource":{"id":"sb-1","type":"capsule"}}\n' + "\n" + "event: capsule.destroy\n" + 'data: {"event":"capsule.destroy","resource":{"id":"sb-1","type":"capsule"}}\n' + "\n" + ) + respx.get(f"{BASE}/v1/events/stream").respond( + 200, + headers={"content-type": "text/event-stream"}, + content=body, + ) + events = list(client.events.stream()) + assert [e.event.value for e in events] == [ + "capsule.create", + "capsule.destroy", + ] + assert events[0].resource and events[0].resource.id == "sb-1" + class TestSnapshots: @respx.mock diff --git a/uv.lock b/uv.lock index 0dc0352..8ce86a6 100644 --- a/uv.lock +++ b/uv.lock @@ -285,15 +285,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, ] -[[package]] -name = "dnspython" -version = "2.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, -] - [[package]] name = "docspec" version = "2.2.1" @@ -328,19 +319,6 @@ version = "0.11" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a2/ce/5d6a3782b9f88097ce3e579265015db3372ae78d12f67629b863a9208c96/docstring_parser-0.11.tar.gz", hash = "sha256:93b3f8f481c7d24e37c5d9f30293c89e2933fa209421c8abd731dd3ef0715ecb", size = 22775, upload-time = "2021-09-30T07:44:10.288Z" } -[[package]] -name = "email-validator" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "dnspython" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, -] - [[package]] name = "filelock" version = "3.29.0" @@ -1166,11 +1144,10 @@ wheels = [ [[package]] name = "wrenn" -version = "0.2.0" +version = "0.3.0" source = { editable = "." } dependencies = [ { name = "certifi" }, - { name = "email-validator" }, { name = "httpx" }, { name = "httpx-ws" }, { name = "pydantic" }, @@ -1184,6 +1161,7 @@ dev = [ { name = "pydoc-markdown" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pyyaml" }, { name = "respx" }, { name = "ruff" }, ] @@ -1191,7 +1169,6 @@ dev = [ [package.metadata] requires-dist = [ { name = "certifi", specifier = ">=2026.2.25" }, - { name = "email-validator", specifier = ">=2.3.0" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "httpx-ws", specifier = ">=0.9.0" }, { name = "pydantic", specifier = ">=2.12.5" }, @@ -1205,6 +1182,7 @@ dev = [ { name = "pydoc-markdown", specifier = ">=4.8.2" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-asyncio", specifier = ">=1.3.0" }, + { name = "pyyaml", specifier = ">=6.0.3" }, { name = "respx", specifier = ">=0.23.1" }, { name = "ruff", specifier = ">=0.15.10" }, ]