2 Commits
v0.1.4 ... main

Author SHA1 Message Date
4924237a23 v0.2.0 (#14)
All checks were successful
ci/woodpecker/push/unit Pipeline was successful
Co-authored-by: Tasnim Kabir Sadik <tksadik92@gmail.com>
Reviewed-on: #14
Co-authored-by: pptx704 <rafeed@omukk.dev>
Co-committed-by: pptx704 <rafeed@omukk.dev>
2026-05-24 05:02:08 +00:00
de72dfe9c8 v0.1.5 (#13)
All checks were successful
ci/woodpecker/push/unit Pipeline was successful
Co-authored-by: Tasnim Kabir Sadik <tksadik92@gmail.com>
Reviewed-on: #13
2026-05-22 23:01:46 +00:00
10 changed files with 379 additions and 816 deletions

View File

@ -172,6 +172,8 @@ import sys
# Stream a new command
for event in capsule.commands.stream("python", args=["-u", "train.py"]):
match event.type:
case "start":
print(f"PID: {event.pid}")
case "stdout":
print(event.data, end="")
case "stderr":
@ -181,8 +183,11 @@ for event in capsule.commands.stream("python", args=["-u", "train.py"]):
# Connect to a running background process
for event in capsule.commands.connect(handle.pid):
if event.type == "stdout":
print(event.data, end="")
match event.type:
case "start":
print(f"PID: {event.pid}")
case "stdout":
print(event.data, end="")
```
#### Process Management
@ -211,6 +216,7 @@ capsule.files.exists("/app/main.py") # True
# List directory
entries = capsule.files.list("/home/user", depth=1)
# FileEntry has: name, type (file/dir), size, modified_at
for entry in entries:
print(entry.name, entry.type, entry.size)
@ -289,8 +295,27 @@ value = capsule.git.get_config("user.name", cwd="/app") # str | None
capsule.git.remote_add("upstream", "https://github.com/org/repo.git", cwd="/app")
url = capsule.git.remote_get("origin", cwd="/app") # str | None
# Reset and restore
capsule.git.reset(mode="hard", ref="HEAD~1", cwd="/app")
capsule.git.restore(["file.txt"], staged=True, cwd="/app")
```
#### Persistent Credential Store
For workflows that need repeated authenticated operations, you can persist credentials via the git credential store:
```python
capsule.git.dangerously_authenticate(
username="user",
password="ghp_token",
host="github.com",
protocol="https",
)
```
> **Warning:** Credentials are written in plaintext inside the capsule and are accessible to any process running there. Prefer per-operation `username`/`password` on `clone`, `push`, and `pull` instead.
Git errors raise `GitCommandError` (or `GitAuthError` for authentication failures), both inheriting from `GitError`:
```python
@ -308,7 +333,7 @@ except GitAuthError as e:
```python
import sys
with capsule.pty(cmd="/bin/bash", cols=120, rows=40, cwd="/home/user") as term:
with capsule.pty(cmd="/bin/bash", cols=80, rows=24, cwd="/home/user") as term:
term.write(b"ls -la\n")
for event in term:
if event.type == "output":
@ -451,9 +476,10 @@ result = capsule.run_code("print('running on custom template')")
| `logs` | `Logs` | `.stdout: list[str]` and `.stderr: list[str]` chunks |
| `error` | `ExecutionError \| None` | `.name`, `.value`, `.traceback` |
| `execution_count` | `int \| None` | Jupyter cell execution counter |
| `timed_out` | `bool` | ``True`` when execution was cut short by the timeout |
| `text` | `str \| None` | (property) `text/plain` of the main `execute_result` |
Each `Result` has typed MIME fields: `text`, `html`, `markdown`, `svg`, `png`, `jpeg`, `pdf`, `latex`, `json`, `javascript`, plus `extra` for unknown types. The `text` field is Jupyter's `text/plain` bundle verbatim — the Python `repr()` of the cell's last expression. So `run_code("'hi'").text` is `"'hi'"` (with quotes), and `run_code("42").text` is `"42"`. This preserves the distinction between the string `'2'` and the int `2`.
Each `Result` has typed MIME fields: `text`, `html`, `markdown`, `svg`, `png`, `jpeg`, `gif`, `pdf`, `latex`, `json`, `javascript`, `plotly`, plus `extra` for unknown types. The `text` field is Jupyter's `text/plain` bundle verbatim — the Python `repr()` of the cell's last expression. So `run_code("'hi'").text` is `"'hi'"` (with quotes), and `run_code("42").text` is `"42"`. This preserves the distinction between the string `'2'` and the int `2`.
### Code Runner + Commands/Files
@ -527,15 +553,15 @@ The SDK maps server error codes to typed exceptions:
```python
from wrenn import (
WrennError,
WrennValidationError, # 400
WrennAuthenticationError, # 401
WrennForbiddenError, # 403
WrennNotFoundError, # 404
WrennConflictError, # 409
WrennHostHasCapsulesError, # 409 (host has running capsules)
WrennAgentError, # 502
WrennInternalError, # 500
WrennHostUnavailableError, # 503
WrennValidationError, # 400
WrennAuthenticationError, # 401
WrennForbiddenError, # 403
WrennNotFoundError, # 404
WrennConflictError, # 409
WrennHostHasCapsulesError, # 409 (host has running capsules)
WrennInternalError, # 500
WrennAgentError, # 502
WrennHostUnavailableError, # 503
)
try:
@ -603,7 +629,7 @@ with WrennClient(api_key="wrn_...") as client:
# Snapshots
template = client.snapshots.create(capsule_id="cl-abc", name="my-snap")
templates = client.snapshots.list()
templates = client.snapshots.list(type="custom") # optional type filter
client.snapshots.delete("my-snap")
```

View File

@ -1421,10 +1421,19 @@ paths:
- apiKeyAuth: []
- sessionAuth: []
description: |
Live snapshot: briefly pauses the capsule, writes its VM state +
memory + flattened rootfs to a new template directory, then resumes
the capsule. The source capsule keeps running after the snapshot;
the resulting template can be used to create new capsules.
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.
@ -1435,14 +1444,14 @@ paths:
schema:
$ref: "#/components/schemas/CreateSnapshotRequest"
responses:
"201":
description: Snapshot created
"202":
description: Snapshot accepted; capsule is now snapshotting
content:
application/json:
schema:
$ref: "#/components/schemas/Template"
$ref: "#/components/schemas/Capsule"
"409":
description: Name already exists or capsule not running
description: Name already exists, or capsule is not running or paused
content:
application/json:
schema:
@ -2707,14 +2716,39 @@ paths:
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: Teams list
description: Paginated teams list
content:
application/json:
schema:
type: array
items: {type: object}
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:
@ -2734,12 +2768,20 @@ paths:
application/json:
schema:
type: object
required: [byoc]
required: [enabled]
properties:
byoc: {type: boolean}
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:
@ -2756,6 +2798,38 @@ paths:
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:
@ -2813,7 +2887,7 @@ paths:
schema:
type: array
items:
$ref: "#/components/schemas/Template"
$ref: "#/components/schemas/AdminTemplate"
/v1/admin/templates/{name}:
delete:
@ -2899,6 +2973,26 @@ paths:
"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)
@ -2969,6 +3063,10 @@ paths:
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:
@ -2977,21 +3075,22 @@ paths:
required: true
schema: {type: string}
requestBody:
required: true
required: false
content:
application/json:
schema:
type: object
required: [name]
properties:
name: {type: string}
name:
type: string
description: Optional; an auto-generated name is used when omitted.
responses:
"201":
description: Snapshot created
"202":
description: Snapshot accepted; capsule is now snapshotting
content:
application/json:
schema:
$ref: "#/components/schemas/Template"
$ref: "#/components/schemas/Capsule"
/v1/admin/capsules/{id}/exec:
parameters:
@ -3486,7 +3585,7 @@ components:
properties:
template:
type: string
default: minimal
default: minimal-ubuntu
vcpus:
type: integer
default: 1
@ -3547,10 +3646,6 @@ components:
type: integer
memory_mb_reserved:
type: integer
sampled_at:
type: string
format: date-time
nullable: true
peaks:
type: object
description: Maximum values over the last 30 days.
@ -3590,7 +3685,7 @@ components:
type: string
status:
type: string
enum: [pending, starting, running, pausing, paused, resuming, stopping, hibernated, stopped, missing, error]
enum: [pending, starting, running, pausing, paused, snapshotting, resuming, stopping, hibernated, stopped, missing, error]
template:
type: string
vcpus:
@ -3599,10 +3694,6 @@ components:
type: integer
timeout_sec:
type: integer
guest_ip:
type: string
host_ip:
type: string
created_at:
type: string
format: date-time
@ -3627,7 +3718,11 @@ components:
agent_version, envd_version) when running.
disk_size_mb:
type: integer
nullable: true
description: Maximum disk capacity in MiB.
disk_used_mb:
type: integer
format: int64
description: Current disk usage in MiB. Only populated on individual capsule GET; omitted in list responses.
CreateSnapshotRequest:
type: object
@ -3664,13 +3759,51 @@ components:
type: boolean
description: |
True when the template is platform-managed (visible to all teams,
e.g. the built-in `minimal` rootfs). False for team-owned
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,
minimal-alpine, minimal-arch, minimal-fedora). Protected templates
cannot be deleted.
metadata:
type: object
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]
@ -3941,6 +4074,25 @@ components:
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
@ -4052,6 +4204,39 @@ components:
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:

View File

@ -1,6 +1,6 @@
[project]
name = "wrenn"
version = "0.1.4"
version = "0.2.0"
description = "Python SDK for Wrenn"
readme = "README.md"
license = "MIT"

View File

@ -164,4 +164,17 @@ def __getattr__(name: str) -> type:
stacklevel=2,
)
return WrennHostHasCapsulesError
if name in ("GitError", "GitCommandError", "GitAuthError"):
from wrenn._git.exceptions import (
GitAuthError as _GitAuthError,
GitCommandError as _GitCommandError,
GitError as _GitError,
)
_m: dict[str, type] = {
"GitError": _GitError,
"GitCommandError": _GitCommandError,
"GitAuthError": _GitAuthError,
}
return _m[name]
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

View File

@ -1,65 +1,17 @@
from wrenn.models._generated import (
APIKeyResponse,
Capsule,
CreateAPIKeyRequest,
CreateCapsuleRequest,
CreateHostRequest,
CreateHostResponse,
CreateSnapshotRequest,
Encoding,
Error,
Error1,
ExecRequest,
ExecResponse,
FileEntry,
Host,
ListDirRequest,
ListDirResponse,
LoginRequest,
MakeDirRequest,
MakeDirResponse,
ReadFileRequest,
RegisterHostRequest,
RegisterHostResponse,
RemoveRequest,
SignupRequest,
Status,
Status1,
Template,
Type,
Type1,
Type2,
)
__all__ = [
"APIKeyResponse",
"CreateAPIKeyRequest",
"CreateHostRequest",
"CreateHostResponse",
"CreateCapsuleRequest",
"CreateSnapshotRequest",
"Encoding",
"Error",
"Error1",
"ExecRequest",
"ExecResponse",
"FileEntry",
"Host",
"ListDirRequest",
"ListDirResponse",
"LoginRequest",
"MakeDirRequest",
"MakeDirResponse",
"ReadFileRequest",
"RegisterHostRequest",
"RegisterHostResponse",
"RemoveRequest",
"Capsule",
"SignupRequest",
"FileEntry",
"ListDirResponse",
"MakeDirResponse",
"Status",
"Status1",
"Template",
"Type",
"Type1",
"Type2",
]

View File

@ -1,153 +1,20 @@
# generated by datamodel-codegen:
# filename: openapi.yaml
# timestamp: 2026-05-19T08:54:50+00:00
# timestamp: 2026-05-23T11:20:02+00:00
from __future__ import annotations
from pydantic import AwareDatetime, BaseModel, EmailStr, Field
from typing import Annotated, Any
from datetime import date as date_aliased
from pydantic import AwareDatetime, BaseModel, Field
from typing import Annotated
from enum import StrEnum
class SignupRequest(BaseModel):
email: EmailStr
password: Annotated[str, Field(min_length=8)]
name: Annotated[str, Field(max_length=100)]
class LoginRequest(BaseModel):
email: EmailStr
password: str
class SignupResponse(BaseModel):
message: Annotated[
str | None,
Field(description="Confirmation message instructing user to check email"),
] = None
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.
"""
user_id: str | None = None
team_id: str | None = None
email: str | None = None
name: str | None = None
role: str | None = None
is_admin: bool | None = None
class CreateAPIKeyRequest(BaseModel):
name: str | None = "Unnamed API Key"
class APIKeyResponse(BaseModel):
id: str | None = None
team_id: str | None = None
name: str | None = None
key_prefix: Annotated[
str | None, Field(description='Display prefix (e.g. "wrn_ab12cd34...")')
] = None
created_at: AwareDatetime | None = None
last_used: AwareDatetime | None = None
key: Annotated[
str | None,
Field(
description="Full plaintext key. Only returned on creation, never again."
),
] = None
class CreateCapsuleRequest(BaseModel):
template: str | None = "minimal"
vcpus: int | None = 1
memory_mb: int | None = 512
disk_size_mb: Annotated[
int | None,
Field(
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).\n"
),
] = 5120
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
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
sampled_at: AwareDatetime | 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"
running = "running"
pausing = "pausing"
paused = "paused"
snapshotting = "snapshotting"
resuming = "resuming"
stopping = "stopping"
hibernated = "hibernated"
@ -163,8 +30,6 @@ class Capsule(BaseModel):
vcpus: int | None = None
memory_mb: int | None = None
timeout_sec: int | None = None
guest_ip: str | None = None
host_ip: str | None = None
created_at: AwareDatetime | None = None
started_at: AwareDatetime | None = None
last_active_at: AwareDatetime | None = None
@ -175,16 +40,14 @@ class Capsule(BaseModel):
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"
),
] = None
disk_size_mb: int | None = 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."),
disk_size_mb: Annotated[
int | None, Field(description="Maximum disk capacity in MiB.")
] = None
disk_used_mb: Annotated[
int | None,
Field(
description="Current disk usage in MiB. Only populated on individual capsule GET; omitted in list responses."
),
] = None
@ -203,100 +66,19 @@ class Template(BaseModel):
platform: Annotated[
bool | None,
Field(
description="True when the template is platform-managed (visible to all teams,\ne.g. the built-in `minimal` rootfs). False for team-owned\nsnapshot templates.\n"
description="True when the template is platform-managed (visible to all teams,\ne.g. the built-in `minimal-ubuntu` rootfs). False for team-owned\nsnapshot templates.\n"
),
] = None
protected: Annotated[
bool | None,
Field(
description="True for built-in system base templates (minimal-ubuntu,\nminimal-alpine, minimal-arch, minimal-fedora). Protected templates\ncannot be deleted.\n"
),
] = None
metadata: dict[str, str] | None = None
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 (background exec only)"
),
] = None
cwd: Annotated[
str | None,
Field(description="Working directory for the process (background exec only)"),
] = 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):
class Type2(StrEnum):
file = "file"
directory = "directory"
symlink = "symlink"
@ -305,7 +87,7 @@ class Type1(StrEnum):
class FileEntry(BaseModel):
name: str | None = None
path: str | None = None
type: Type1 | None = None
type: Type2 | None = None
size: int | None = None
mode: int | None = None
permissions: Annotated[
@ -319,438 +101,9 @@ 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 Type2(StrEnum):
"""
Host type. Regular hosts are shared; BYOC hosts belong to a team.
"""
regular = "regular"
byoc = "byoc"
class CreateHostRequest(BaseModel):
type: Annotated[
Type2,
Field(
description="Host type. Regular hosts are shared; BYOC hosts belong to a team."
),
]
team_id: Annotated[str | None, Field(description="Required for BYOC hosts.")] = None
provider: Annotated[
str | None,
Field(description="Cloud provider (e.g. aws, gcp, hetzner, bare-metal)."),
] = None
availability_zone: Annotated[
str | None, Field(description="Availability zone (e.g. us-east, eu-west).")
] = None
class RegisterHostRequest(BaseModel):
token: Annotated[
str, Field(description="One-time registration token from POST /v1/hosts.")
]
arch: Annotated[
str | None, Field(description="CPU architecture (e.g. x86_64, aarch64).")
] = None
cpu_cores: int | None = None
memory_mb: int | None = None
disk_gb: int | None = None
address: Annotated[str, Field(description="Host agent address (ip:port).")]
class Type3(StrEnum):
regular = "regular"
byoc = "byoc"
class Status1(StrEnum):
pending = "pending"
online = "online"
offline = "offline"
draining = "draining"
unreachable = "unreachable"
class Host(BaseModel):
id: str | None = None
type: Type3 | None = None
team_id: str | None = None
provider: str | None = None
availability_zone: str | None = None
arch: str | None = None
cpu_cores: int | None = None
memory_mb: int | None = None
disk_gb: int | None = None
address: str | None = None
status: Status1 | None = None
last_heartbeat_at: AwareDatetime | None = None
created_by: str | None = None
created_at: AwareDatetime | None = None
updated_at: AwareDatetime | None = None
class RefreshHostTokenRequest(BaseModel):
refresh_token: Annotated[
str,
Field(
description="Refresh token obtained from registration or a previous refresh."
),
]
class RefreshHostTokenResponse(BaseModel):
host: Host | None = None
token: Annotated[
str | None, Field(description="New host JWT. Valid for 7 days.")
] = None
refresh_token: Annotated[
str | None,
Field(
description="New refresh token. Valid for 60 days; old token is revoked."
),
] = None
class HostDeletePreview(BaseModel):
host: Host | None = None
sandbox_ids: Annotated[
list[str] | None,
Field(description="IDs of capsules that would be destroyed on force-delete."),
] = None
class Error(BaseModel):
code: Annotated[str | None, Field(examples=["host_has_sandboxes"])] = None
message: str | None = None
sandbox_ids: Annotated[
list[str] | None, Field(description="IDs of active capsules blocking deletion.")
] = None
class HostHasCapsulesError(BaseModel):
error: Error | None = None
class AddTagRequest(BaseModel):
tag: str
class UserSearchResult(BaseModel):
user_id: str | None = None
email: str | None = None
class Team(BaseModel):
id: str | None = None
name: str | None = None
slug: Annotated[
str | None, Field(description="Immutable 12-char hex slug (e.g. a1b2c3-d1e2f3)")
] = None
created_at: AwareDatetime | None = None
class Role(StrEnum):
owner = "owner"
admin = "admin"
member = "member"
class TeamWithRole(Team):
role: Role | None = None
class TeamMember(BaseModel):
user_id: str | None = None
email: str | None = None
role: Role | None = None
joined_at: AwareDatetime | None = None
class TeamDetail(BaseModel):
team: Team | None = None
members: list[TeamMember] | None = None
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 Provider(StrEnum):
discord = "discord"
slack = "slack"
teams = "teams"
googlechat = "googlechat"
telegram = "telegram"
matrix = "matrix"
webhook = "webhook"
class Event(StrEnum):
capsule_create = "capsule.create"
capsule_pause = "capsule.pause"
capsule_resume = "capsule.resume"
capsule_destroy = "capsule.destroy"
template_snapshot_create = "template.snapshot.create"
template_snapshot_delete = "template.snapshot.delete"
host_up = "host.up"
host_down = "host.down"
class CreateChannelRequest(BaseModel):
name: Annotated[str, Field(description="Unique channel name within the team.")]
provider: Provider
config: Annotated[
dict[str, str],
Field(
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).\n'
),
]
events: list[Event]
class TestChannelRequest(BaseModel):
provider: Provider
config: Annotated[
dict[str, str],
Field(
description="Provider-specific configuration fields (same as CreateChannelRequest.config)."
),
]
class RotateConfigRequest(BaseModel):
config: Annotated[
dict[str, str],
Field(
description="New provider configuration fields. Must include all required fields for the channel's provider. Replaces the existing config entirely.\n"
),
]
class UpdateChannelRequest(BaseModel):
name: str
events: list[Event]
class ChannelResponse(BaseModel):
id: str | None = None
team_id: str | None = None
name: str | None = None
provider: Provider | None = None
events: list[str] | None = None
created_at: AwareDatetime | None = None
updated_at: AwareDatetime | None = None
secret: Annotated[
str | None,
Field(description="Webhook secret. Only returned on creation, never again."),
] = None
class MeResponse(BaseModel):
name: str | None = None
email: EmailStr | None = None
has_password: Annotated[
bool | None,
Field(
description="Whether the user has a password set (false for OAuth-only accounts)"
),
] = None
providers: Annotated[
list[str] | None,
Field(description='List of linked OAuth provider names (e.g. ["github"])'),
] = None
class ChangePasswordRequest(BaseModel):
current_password: Annotated[
str | None, Field(description="Required when changing an existing password")
] = None
new_password: Annotated[str, Field(min_length=8)]
confirm_password: Annotated[
str | None,
Field(
description="Required when adding a password to an OAuth-only account (must match new_password)"
),
] = None
class Error2(BaseModel):
code: str | None = None
message: str | None = None
class Error1(BaseModel):
error: Error2 | None = None
class ActorType(StrEnum):
user = "user"
api_key = "api_key"
host = "host"
system = "system"
class Status2(StrEnum):
success = "success"
failure = "failure"
class AuditLogEntry(BaseModel):
id: str | None = None
actor_type: ActorType | None = None
actor_id: str | None = None
actor_name: str | None = None
resource_type: str | None = None
resource_id: str | None = None
action: str | None = None
scope: str | None = None
status: Status2 | None = None
metadata: dict[str, Any] | None = None
created_at: AwareDatetime | None = None
class Event2(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 Type4(StrEnum):
user = "user"
api_key = "api_key"
system = "system"
class Actor(BaseModel):
type: Type4 | 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: Event2 | 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 CreateHostResponse(BaseModel):
host: Host | None = None
registration_token: Annotated[
str | None,
Field(
description="One-time registration token for the host agent. Expires in 1 hour."
),
] = None
class RegisterHostResponse(BaseModel):
host: Host | None = None
token: Annotated[
str | None,
Field(description="Host JWT for X-Host-Token header. Valid for 7 days."),
] = None
refresh_token: Annotated[
str | None,
Field(
description="Refresh token for obtaining new JWTs. Valid for 60 days; rotated on each use."
),
] = None
class CapsuleMetrics(BaseModel):
sandbox_id: str | None = None
range: Range1 | None = None
points: list[MetricPoint] | None = None

View File

@ -74,32 +74,32 @@ class TestFilesList:
"entries": [
{
"name": "main.py",
"path": "/home/user/main.py",
"path": "/home/wrenn-user/main.py",
"type": "file",
"size": 1024,
"mode": 33188,
"permissions": "-rw-r--r--",
"owner": "root",
"group": "root",
"owner": "wrenn-user",
"group": "wrenn-user",
"modified_at": 1712899200,
"symlink_target": None,
},
{
"name": "config",
"path": "/home/user/config",
"path": "/home/wrenn-user/config",
"type": "directory",
"size": 4096,
"mode": 16877,
"permissions": "drwxr-xr-x",
"owner": "root",
"group": "root",
"owner": "wrenn-user",
"group": "wrenn-user",
"modified_at": 1712899100,
"symlink_target": None,
},
]
},
)
entries = cap.files.list("/home/user")
entries = cap.files.list("/home/wrenn-user")
assert len(entries) == 2
assert isinstance(entries[0], FileEntry)
assert entries[0].name == "main.py"
@ -113,7 +113,7 @@ class TestFilesList:
route = respx.post(f"{BASE}/v1/capsules/cl-abc/files/list").respond(
200, json={"entries": []}
)
cap.files.list("/home/user", depth=3)
cap.files.list("/home/wrenn-user", depth=3)
body = json.loads(route.calls[0].request.content)
assert body["depth"] == 3
@ -136,19 +136,19 @@ class TestFilesMakeDir:
json={
"entry": {
"name": "data",
"path": "/home/user/data",
"path": "/home/wrenn-user/data",
"type": "directory",
"size": 4096,
"mode": 16877,
"permissions": "drwxr-xr-x",
"owner": "root",
"group": "root",
"owner": "wrenn-user",
"group": "wrenn-user",
"modified_at": 1712899200,
"symlink_target": None,
}
},
)
entry = cap.files.make_dir("/home/user/data")
entry = cap.files.make_dir("/home/wrenn-user/data")
assert isinstance(entry, FileEntry)
assert entry.name == "data"
assert entry.type == "directory"
@ -166,20 +166,20 @@ class TestFilesMakeDir:
"entries": [
{
"name": "data",
"path": "/home/user/data",
"path": "/home/wrenn-user/data",
"type": "directory",
"size": 4096,
"mode": 16877,
"permissions": "drwxr-xr-x",
"owner": "root",
"group": "root",
"owner": "wrenn-user",
"group": "wrenn-user",
"modified_at": 1712899200,
"symlink_target": None,
}
]
},
)
entry = cap.files.make_dir("/home/user/data")
entry = cap.files.make_dir("/home/wrenn-user/data")
assert entry.name == "data"
@ -188,7 +188,7 @@ class TestFilesRemove:
def test_remove_succeeds(self):
cap = _make_capsule()
route = respx.post(f"{BASE}/v1/capsules/cl-abc/files/remove").respond(204)
cap.files.remove("/home/user/old_data")
cap.files.remove("/home/wrenn-user/old_data")
assert route.called
@respx.mock
@ -411,7 +411,7 @@ class TestPtySessionSendStart:
cols=120,
rows=40,
envs={"TERM": "xterm-256color"},
cwd="/home/user",
cwd="/home/wrenn-user",
)
sent = json.loads(ws.send_text.call_args[0][0])
assert sent["cmd"] == "/bin/zsh"

View File

@ -323,7 +323,7 @@ class TestFiles:
class TestGit:
"""Shared capsule for git operation tests.
Initializes a repo at /root (default cwd) since the exec API
Initializes a repo at /home/wrenn-user (default cwd) since the exec API
does not support the cwd parameter.
"""
@ -344,14 +344,14 @@ class TestGit:
pass
def test_init_created_repo(self):
assert self.capsule.files.exists("/root/.git")
assert self.capsule.files.exists("/home/wrenn-user/.git")
def test_status_clean(self):
status = self.capsule.git.status()
assert status.branch == "main"
def test_add_and_commit(self):
self.capsule.files.write("/root/hello.txt", "hello git")
self.capsule.files.write("/home/wrenn-user/hello.txt", "hello git")
self.capsule.git.add(all=True)
result = self.capsule.git.commit("initial commit")
assert result.exit_code == 0
@ -361,14 +361,14 @@ class TestGit:
assert status.is_clean
def test_status_with_changes(self):
self.capsule.files.write("/root/dirty.txt", "uncommitted")
self.capsule.files.write("/home/wrenn-user/dirty.txt", "uncommitted")
try:
status = self.capsule.git.status()
assert not status.is_clean
paths = [f.path for f in status.files]
assert "dirty.txt" in paths
finally:
self.capsule.files.remove("/root/dirty.txt")
self.capsule.files.remove("/home/wrenn-user/dirty.txt")
def test_branches(self):
branches = self.capsule.git.branches()

View File

@ -75,7 +75,7 @@ class TestCommandEnvironment:
def test_default_cwd_is_home(self):
result = self.capsule.commands.run("pwd")
assert result.stdout.strip() == "/root"
assert result.stdout.strip() == "/home/wrenn-user"
def test_cwd_resolves_relative_paths(self):
self.capsule.files.make_dir("/tmp/cwd_probe/sub")
@ -90,7 +90,7 @@ class TestCommandEnvironment:
# Each run is a fresh process — `cd` in one does not affect the next.
self.capsule.commands.run("cd /tmp")
result = self.capsule.commands.run("pwd")
assert result.stdout.strip() == "/root"
assert result.stdout.strip() == "/home/wrenn-user"
def test_single_env_var(self):
result = self.capsule.commands.run("echo $GREETING", envs={"GREETING": "hi"})
@ -115,9 +115,29 @@ class TestCommandEnvironment:
def test_base_environment_present(self):
result = self.capsule.commands.run("echo $HOME; echo $PATH")
lines = result.stdout.strip().splitlines()
assert lines[0] == "/root"
assert lines[0] == "/home/wrenn-user"
assert "/usr/bin" in lines[1]
def test_sudo_available(self):
result = self.capsule.commands.run("which sudo")
assert result.exit_code == 0
def test_sudo_runs_without_password(self):
result = self.capsule.commands.run("sudo whoami")
assert result.exit_code == 0
assert result.stdout.strip() == "root"
def test_sudo_can_write_to_protected_path(self):
result = self.capsule.commands.run(
"sudo touch /opt/sudo-test-marker && cat /opt/sudo-test-marker"
)
assert result.exit_code == 0
def test_sudo_can_read_root_owned_file(self):
result = self.capsule.commands.run("sudo cat /etc/shadow | head -1")
assert result.exit_code == 0
assert "root" in result.stdout
# ══════════════════════════════════════════════════════════════════
# Long-running commands
@ -143,7 +163,7 @@ class TestLongRunningCommands:
def test_apt_get_install(self):
result = self.capsule.commands.run(
"apt-get update -qq && apt-get install -y -qq cowsay", timeout=300
"sudo apt-get update -qq && sudo apt-get install -y -qq cowsay", timeout=300
)
assert result.exit_code == 0
@ -388,7 +408,9 @@ class TestGitClone:
def setup_class(cls):
_ensure_env()
cls.capsule = Capsule(wait=True)
cls.capsule.git.clone(WRENN_REPO, "/root/wrenn", depth=1, timeout=300)
cls.capsule.git.clone(
WRENN_REPO, "/home/wrenn-user/wrenn", depth=1, timeout=300
)
@classmethod
def teardown_class(cls):
@ -398,66 +420,74 @@ class TestGitClone:
pass
def test_clone_created_repo(self):
assert self.capsule.files.exists("/root/wrenn/.git")
assert self.capsule.files.exists("/home/wrenn-user/wrenn/.git")
def test_clone_checked_out_files(self):
entries = self.capsule.files.list("/root/wrenn")
entries = self.capsule.files.list("/home/wrenn-user/wrenn")
names = [e.name for e in entries]
assert "README.md" in names
def test_status_of_clone_is_clean(self):
status = self.capsule.git.status(cwd="/root/wrenn")
status = self.capsule.git.status(cwd="/home/wrenn-user/wrenn")
assert status.branch == "main"
assert status.is_clean
def test_branches_lists_main(self):
branches = self.capsule.git.branches(cwd="/root/wrenn")
branches = self.capsule.git.branches(cwd="/home/wrenn-user/wrenn")
names = [b.name for b in branches]
assert "main" in names
assert any(b.is_current for b in branches)
def test_remote_get_origin(self):
url = self.capsule.git.remote_get("origin", cwd="/root/wrenn")
url = self.capsule.git.remote_get("origin", cwd="/home/wrenn-user/wrenn")
assert url is not None
assert "wrennhq/wrenn" in url
def test_git_log_has_commit(self):
result = self.capsule.commands.run("git log --oneline -1", cwd="/root/wrenn")
result = self.capsule.commands.run(
"git log --oneline -1", cwd="/home/wrenn-user/wrenn"
)
assert result.exit_code == 0
assert result.stdout.strip()
def test_modify_add_commit(self):
marker = uuid.uuid4().hex
self.capsule.git.configure_user(
"CI Bot", "ci@example.com", cwd="/root/wrenn", scope="local"
"CI Bot", "ci@example.com", cwd="/home/wrenn-user/wrenn", scope="local"
)
self.capsule.files.write(f"/root/wrenn/sdk_probe_{marker}.txt", marker)
self.capsule.git.add([f"sdk_probe_{marker}.txt"], cwd="/root/wrenn")
self.capsule.files.write(
f"/home/wrenn-user/wrenn/sdk_probe_{marker}.txt", marker
)
self.capsule.git.add([f"sdk_probe_{marker}.txt"], cwd="/home/wrenn-user/wrenn")
staged = self.capsule.git.status(cwd="/root/wrenn")
staged = self.capsule.git.status(cwd="/home/wrenn-user/wrenn")
assert staged.has_staged
result = self.capsule.git.commit("probe commit", cwd="/root/wrenn")
result = self.capsule.git.commit("probe commit", cwd="/home/wrenn-user/wrenn")
assert result.exit_code == 0
after = self.capsule.git.status(cwd="/root/wrenn")
after = self.capsule.git.status(cwd="/home/wrenn-user/wrenn")
assert after.is_clean
assert after.ahead >= 1
def test_create_and_checkout_branch_in_clone(self):
self.capsule.git.create_branch("sdk-feature", cwd="/root/wrenn")
branches = self.capsule.git.branches(cwd="/root/wrenn")
self.capsule.git.create_branch("sdk-feature", cwd="/home/wrenn-user/wrenn")
branches = self.capsule.git.branches(cwd="/home/wrenn-user/wrenn")
current = [b for b in branches if b.is_current]
assert current and current[0].name == "sdk-feature"
self.capsule.git.checkout_branch("main", cwd="/root/wrenn")
self.capsule.git.checkout_branch("main", cwd="/home/wrenn-user/wrenn")
def test_diff_via_commands(self):
self.capsule.files.write("/root/wrenn/README.md", "overwritten\n")
self.capsule.files.write("/home/wrenn-user/wrenn/README.md", "overwritten\n")
try:
result = self.capsule.commands.run("git diff --stat", cwd="/root/wrenn")
result = self.capsule.commands.run(
"git diff --stat", cwd="/home/wrenn-user/wrenn"
)
assert "README.md" in result.stdout
finally:
self.capsule.git.restore(["README.md"], worktree=True, cwd="/root/wrenn")
self.capsule.git.restore(
["README.md"], worktree=True, cwd="/home/wrenn-user/wrenn"
)
class TestGitErrors:
@ -481,7 +511,7 @@ class TestGitErrors:
with pytest.raises(GitError):
self.capsule.git.clone(
"https://github.com/wrennhq/this-repo-does-not-exist-xyz",
"/root/missing",
"/home/wrenn-user/missing",
timeout=120,
)
@ -493,7 +523,11 @@ class TestGitErrors:
def test_clone_with_branch(self):
self.capsule.git.clone(
WRENN_REPO, "/root/wrenn-main", branch="main", depth=1, timeout=300
WRENN_REPO,
"/home/wrenn-user/wrenn-main",
branch="main",
depth=1,
timeout=300,
)
status = self.capsule.git.status(cwd="/root/wrenn-main")
status = self.capsule.git.status(cwd="/home/wrenn-user/wrenn-main")
assert status.branch == "main"

2
uv.lock generated
View File

@ -1166,7 +1166,7 @@ wheels = [
[[package]]
name = "wrenn"
version = "0.1.4"
version = "0.2.0"
source = { editable = "." }
dependencies = [
{ name = "certifi" },