60 Commits

Author SHA1 Message Date
a2d9aac78b Version bump
All checks were successful
ci/woodpecker/push/unit Pipeline was successful
ci/woodpecker/pr/unit Pipeline was successful
ci/woodpecker/pr/code-runner Pipeline was successful
ci/woodpecker/pr/integration Pipeline was successful
2026-05-23 17:44:34 +06:00
4dcbc73003 chore: remove unused generated models, expose git exceptions from wrenn.exceptions 2026-05-23 17:39:51 +06:00
7b04ba3949 Merge branch 'main' of git.omukk.dev:wrenn/python-sdk into dev 2026-05-23 16:41:25 +06: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
27cfe42b3c Merge pull request 'Updated tests for default non-root user update' (#12) from fix/non-root-user into dev
Some checks failed
ci/woodpecker/push/unit Pipeline was successful
ci/woodpecker/pr/unit Pipeline was successful
ci/woodpecker/pr/integration Pipeline failed
ci/woodpecker/pr/code-runner Pipeline failed
Reviewed-on: #12
2026-05-22 23:01:15 +00:00
f218bdd63e Merge branch 'dev' into fix/non-root-user
Some checks failed
ci/woodpecker/push/unit Pipeline was successful
ci/woodpecker/pr/code-runner Pipeline failed
ci/woodpecker/pr/unit Pipeline was successful
ci/woodpecker/pr/integration Pipeline failed
2026-05-22 23:01:06 +00:00
8bec8ed2c0 Merge branch 'main' into dev
Some checks failed
ci/woodpecker/push/unit Pipeline was successful
ci/woodpecker/pr/unit Pipeline was successful
ci/woodpecker/pr/integration Pipeline was canceled
ci/woodpecker/pr/code-runner Pipeline was canceled
2026-05-22 23:00:38 +00:00
9141f2c703 Version bump
All checks were successful
ci/woodpecker/push/unit Pipeline was successful
ci/woodpecker/pr/unit Pipeline was successful
ci/woodpecker/pr/code-runner Pipeline was successful
ci/woodpecker/pr/integration Pipeline was successful
2026-05-23 04:56:15 +06:00
08314b172b fix: update SDK for non-root user capsules
All checks were successful
ci/woodpecker/push/unit Pipeline was successful
- Update default template from minimal to minimal-ubuntu
- Replace /root and /home/user paths with /home/wrenn-user in tests
- Update file ownership from root:root to wrenn-user:wrenn-user
- Add sudo-related integration tests
- Regenerate pydantic models from updated OpenAPI spec
  - Add snapshotting status, AdminTemplate schema, protected field
  - Rename Type1→Type5 enums to match new spec
- Update OpenAPI spec for async snapshot endpoints
2026-05-23 01:57:34 +06:00
98028bab52 refactor: dry up sync/async pairs, fix resource leaks, sharpen consistency
Some checks failed
ci/woodpecker/push/unit Pipeline was successful
ci/woodpecker/pr/unit Pipeline was successful
ci/woodpecker/pr/code-runner Pipeline was canceled
ci/woodpecker/pr/integration Pipeline was canceled
- fix async client leak in AsyncCapsule.create/connect on failure
- fix websocket cm orphan when __enter__ raises mid-handshake
- code_runner AsyncCapsule.create now delegates via base, mirrors sync
- code_runner AsyncCapsule.__init__ accepts positional params
- extract shared helpers in commands/files/client (payload, multipart,
  snapshot builders)
- code_runner/_protocol gains apply_kernel_message, pick_kernel_id,
  validate_language; run_code + _ensure_kernel dedup'd sync/async
- drop stale wrenn.code_runner.Sandbox alias
- doc + timeout-catch tidy-ups in run_code
2026-05-21 02:53:45 +06:00
7291dbe669 version bump
Some checks failed
ci/woodpecker/push/unit Pipeline was successful
ci/woodpecker/pr/unit Pipeline was successful
ci/woodpecker/pr/integration Pipeline failed
ci/woodpecker/pr/code-runner Pipeline failed
2026-05-21 01:55:43 +06:00
8a62b6207c feat(client): add proxy_domain + timeout kwargs, fix default proxy host
Some checks failed
ci/woodpecker/push/unit Pipeline was successful
ci/woodpecker/pr/unit Pipeline was successful
ci/woodpecker/pr/code-runner Pipeline failed
ci/woodpecker/pr/integration Pipeline was successful
- WrennClient/AsyncWrennClient accept proxy_domain= and timeout= kwargs
- WRENN_PROXY_DOMAIN env var supported
- Default proxy host: app.wrenn.dev -> wrenn.dev (was port-id.app.wrenn.dev)
- Custom base_url preserves host verbatim (with port)
- Default timeout: httpx.Timeout(30.0, connect=10.0)
- _build_proxy_url/_build_http_proxy_url take optional proxy_domain
- code_runner proxy + WS URL builders thread proxy_domain through
2026-05-21 01:46:04 +06:00
db48e3cfbf ci: scope e2e pipelines, exclude code_runner from integration
Some checks failed
ci/woodpecker/push/unit Pipeline was successful
ci/woodpecker/pr/unit Pipeline was successful
ci/woodpecker/pr/integration Pipeline failed
ci/woodpecker/pr/code-runner Pipeline was canceled
2026-05-20 06:42:44 +06:00
fbcedc5317 Merge pull request 'Added more tests, fixed bugs and updated pipeline' (#11) from feat/test-code-interpreter into dev
Some checks failed
ci/woodpecker/push/unit Pipeline was successful
ci/woodpecker/pr/unit Pipeline was successful
ci/woodpecker/pr/integration Pipeline was canceled
ci/woodpecker/pr/code-runner Pipeline was canceled
Reviewed-on: #11
2026-05-20 00:26:22 +00:00
005871441a ci: split Woodpecker pipelines by scope
Some checks failed
ci/woodpecker/push/unit Pipeline was successful
ci/woodpecker/pr/unit Pipeline was successful
ci/woodpecker/pr/code-runner Pipeline was canceled
ci/woodpecker/pr/integration Pipeline was canceled
- unit.yml: unit tests on every push and pull_request, all branches.
- code-runner.yml: PR to dev/main, gated on src/wrenn/code_runner/**
  or tests/test_code_runner_*.py; runs `make test-code-runner`.
- integration.yml: PR to dev/main, gated on src/** excluding
  src/wrenn/code_runner/**; runs `make test-integration`.

E2E pipelines require a src/** change, so docs/test-only PRs only
trigger the unit pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 05:25:19 +06:00
b2ec7f9ab3 refactor: extract jupyter protocol, harden error paths, dedup git ops
- code_runner: split shared Jupyter message/URL helpers into
  `_protocol.py`; surface kernel disconnects and run_code timeouts as
  ExecutionError; add gif and plotly MIME types to Result.
- capsule: introduce `_build_http_proxy_url` so HTTP proxy callers
  stop munging ws:// URLs; `proxy_url()` now returns http(s).
- _git: collapse `_run` + `_check_result` into `_run_op` across sync
  and async Git; drop unused `build_has_upstream`.
- pty: classify unknown msg_types as non-fatal error events instead
  of raising ValueError.
- files: add `Transfer-Encoding: chunked` to streaming uploads.
- ci: remove unused Woodpecker check.yml.
- tests: expand unit coverage for code_runner and capsule features.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 05:23:38 +06:00
9edde7bff5 feat(code_runner): rename module, fix __del__ + kernel name, expand tests
- Rename `wrenn.code_interpreter` → `wrenn.code_runner` (canonical).
  Keep old path as deprecation alias that emits a FutureWarning on
  import, mirroring the existing `Sandbox` → `Capsule` pattern.
  Submodule shims `code_interpreter/{capsule,async_capsule,models}.py`
  keep direct-submodule imports working.

- Fix sync/async ctor-failure-safe `__del__`: initialise `_kernel_id`,
  `_kernel_name`, `_proxy_client` before calling `super().__init__` so
  a failed creation no longer crashes the destructor with
  AttributeError.

- Send the kernel name to Jupyter. Previously `POST /api/kernels` had
  no body, so the server picked an arbitrary default kernelspec. Now
  sends `{"name": "wrenn"}` (override via `Capsule(kernel=...)`) and
  reuses an existing kernel only when its `name` matches.

- Preserve Jupyter `text/plain` verbatim in `Result.from_bundle`.
  The previous outer-quote strip was lossy (the string `'2'` became
  indistinguishable from the int `2`, and strings containing escaped
  quotes were mangled). `text` is now the `repr()` Jupyter sends.
  Updated the stale `test_capsule_features` quote-strip test.

- Validate `run_code(language=...)`. Anything other than `"python"`
  now raises `ValueError` instead of being silently ignored.

- Async `__del__` no longer touches the event loop; users must call
  `await close()` or use `async with`.

- New unit suite `tests/test_code_runner_unit.py` (46 tests): MIME
  unpacking, deprecation alias + warning, default template + kernel,
  custom kernel override, ctor-failure-safe __del__, kernel
  create/reuse/cache, retry on 5xx, 4xx propagation, request shape,
  run_code stream/result/error/foreign-parent/idle/unsupported-language,
  async variants.

- New e2e suite `tests/test_code_runner_e2e.py` (44 tests, integration
  marker): template == `code-runner-beta`, kernel == `wrenn`, stdout
  /stderr capture, state/import/function/class persistence, exceptions
  (Value/Name/Syntax), callbacks, multi-line, `text` repr preservation,
  filesystem round-trip, isolation between capsules, deprecated import
  path. MIME-type class covers html, markdown, json, latex, svg,
  javascript, png (matplotlib + seaborn), jpeg, multi-format bundles,
  and text-round-trip via numpy + requests.

- `make test-code-runner` runs unit + e2e together. `make test`
  extended to include the unit file.

- README: "Code Interpreter" section renamed to "Code Runner", all
  imports updated, `kernel=` documented, removed the incorrect
  "quotes stripped automatically" claim, replaced with the actual
  `text/plain` semantics.

- CLAUDE.md: appended a "Code Runner Module" section covering module
  path, defaults, kernel-reuse semantics, lifecycle invariant, and
  the new test files + make target.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 04:29:31 +06:00
369c75af24 ci: run unit tests on every push
All checks were successful
ci/woodpecker/push/check Pipeline was successful
ci/woodpecker/pr/check Pipeline was successful
ci/woodpecker/pull_request_metadata/check Pipeline was successful
Move per-step `when` filters: unit tests now run on every branch push,
integration tests keep pull_request + main/dev branch restriction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:19:20 +06:00
41ee41e9cd Merge pull request 'fix: update SDK for v0.2.0 API compatibility' (#10) from fix/0.2-compatibility into dev
Some checks failed
ci/woodpecker/pr/check Pipeline failed
Reviewed-on: #10
2026-05-19 11:16:20 +00:00
fce514c49c test: expand command/PTY/git coverage, fix WebSocket close handling
Some checks failed
ci/woodpecker/pr/check Pipeline failed
Tests:
- tests/test_commands.py: unit coverage for Commands/AsyncCommands —
  payload construction (cwd, envs, tag, timeout), background dispatch,
  base64 response decoding, stream-event parsing, stream/connect iterators.
- tests/test_integration_advanced.py: live tests for cwd/env handling,
  long-running commands (apt-get), PTY sessions, streaming exec,
  process connect, and git workflows including cloning wrennhq/wrenn.
- test_filesystem_pty.py: PTY ping/pong reply tests.
- test_integration.py: poll for async process-registry prune in
  test_kill_process instead of asserting on a zero-delay list().

Fixes:
- commands.py / pty.py: stream(), connect() and the PTY iterators only
  caught WebSocketDisconnect. The server closes exec/process streams
  abruptly, raising WebSocketNetworkError — a sibling under
  HTTPXWSException — which crashed connect() entirely. Both are now
  caught via _WS_CLOSED so abrupt closes end iteration cleanly.
- pty.py: reply to the server keepalive ping with a pong so idle PTY
  sessions stay open.
2026-05-19 17:12:52 +06:00
87cc16e9e2 chore: merge origin/dev, bump version to 0.1.4
Resolve conflicts in api/openapi.yaml and src/wrenn/models/_generated.py
by keeping the fix/0.2-compatibility versions (v0.2 API is authoritative).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 15:25:22 +06:00
08f6a1ab84 Merge branch 'main' of git.omukk.dev:wrenn/python-sdk into dev
All checks were successful
ci/woodpecker/pr/check Pipeline was successful
2026-05-19 15:22:46 +06:00
51c6987515 fix: sync SDK with v0.2 API, add wait kwargs to lifecycle ops
- Drop AuthResponse from models __init__ (renamed SessionResponse server-side; SDK auths via API key, doesn't need either)
- Regenerate models from updated 0.2 openapi spec
- Add wait: bool = False kwarg to Capsule/AsyncCapsule destroy/pause/resume (instance + _static_*); 500ms poll for resume/destroy, 2s for pause
- Unify polling into _poll_until / _apoll_until + _wait_for_status helper; remove duplicated _POLL_INTERVALS tables
- wait_ready: drop implicit paused->resume side effect; treat missing as fail
- Capsule.connect: handle transient pausing (wait for paused first) before resuming, fixes hang when caller pauses then connects immediately
- Drop dead "if self._id is None" branch in Capsule.__init__ after assigning from already-truthy _capsule_id
- files.make_dir: detect already_exists across 409/wrapped error messages via shared _is_already_exists helper
- tests/test_integration.py: assertions on final lifecycle state use wait=True
2026-05-19 15:06:49 +06:00
e057ec2407 Merge branch 'main' into dev
All checks were successful
ci/woodpecker/pr/check Pipeline was successful
2026-05-19 07:10:17 +00:00
e5e4e1a85b fix: update SDK for v0.2.0 API compatibility
Some checks failed
ci/woodpecker/pr/check Pipeline failed
Sync OpenAPI spec to v0.2.0, fix type annotation shadowing by using
builtins.list in annotated signatures, guard poll interval lookup
against None status, and reorder capsule ID assignment to validate
before storing.
2026-05-16 17:57:20 +06:00
6112c71abc test: make process kill integration test resilient
Some checks failed
ci/woodpecker/pr/check Pipeline failed
2026-05-16 17:02:25 +06:00
d9c028564e Merge branch 'bugfix/timeout-related-issues' into dev
Some checks failed
ci/woodpecker/pr/check Pipeline failed
2026-05-02 21:53:33 +06:00
06b4a8cbcb Merge issues fixed
All checks were successful
ci/woodpecker/pr/check Pipeline was successful
2026-05-02 21:46:16 +06:00
04e5dc652f Fix error handling, resource leaks, and logic bugs across the SDK
Bugs fixed:
- files.py: use typed error checking (_raise_for_status) instead of raw
  raise_for_status(), ensuring WrennNotFoundError etc. are raised
  correctly
- exceptions.py: check both "capsule_ids" and "sandbox_ids" response
  keys
  for backwards compatibility
- code_interpreter: retry _ensure_kernel on 5xx errors (only fail on
  4xx),
  remove redundant TimeoutError in bare except, clean up non-standard
  top-level msg_id/msg_type from Jupyter messages

Resource leaks fixed:
- capsule.py: close WrennClient if capsule creation or init fails
- code_interpreter: add close()/__del__ for _proxy_client cleanup when
  not using context manager

Logic fixes:
- pty.py: yield exit events to callers instead of silently discarding
  them
- capsule.py: auto-resume paused capsules in wait_ready instead of
  failing
- capsule.py: log warnings on destroy failure in __exit__ instead of
  silently swallowing errors
2026-05-02 21:34:02 +06:00
4a7db8e204 fix: set httpx read timeout for long-running commands and handle
non-JSON error responses
- Set per-request httpx timeout (command timeout + 10s buffer) in
  Commands.run() and AsyncCommands.run() for foreground exec calls,
  preventing HTTP read timeouts on long-running commands
- Raise WrennInternalError instead of raw httpx.HTTPStatusError when
  handle_response() encounters a non-JSON error body (e.g. 502 from
  a reverse proxy)
2026-05-02 19:02:39 +06:00
a76be96682 Merge branch 'main' of git.omukk.dev:wrenn/python-sdk into dev 2026-05-02 05:07:13 +06:00
dc66ac24d5 Updated woodpecker def
All checks were successful
ci/woodpecker/pr/check Pipeline was successful
2026-05-02 04:50:11 +06:00
b5e2b12ef1 Version bump and other minor changes 2026-05-02 04:45:05 +06:00
213af4aee7 Increased timeout for long running API calls and updated typehints 2026-05-02 04:44:26 +06:00
aa9477ffe8 Added doc generator for SDK
All checks were successful
ci/woodpecker/push/check Pipeline was successful
2026-04-24 00:01:20 +06:00
2bb3dbd71d Merge branch 'main' of git.omukk.dev:wrenn/python-sdk into dev 2026-04-23 23:53:15 +06:00
3f26a2fbcf Merge branch 'main' into dev
Some checks failed
ci/woodpecker/push/check Pipeline was canceled
2026-04-23 12:38:41 +00:00
2faf0dd0ae Updated woodpecker config
All checks were successful
ci/woodpecker/push/check Pipeline was successful
2026-04-23 18:36:35 +06:00
68c7d0de42 ci: add test pipeline, PyPI release workflow, and lint fixes
- Update Woodpecker to run unit and integration tests in parallel
- Add GitHub Actions workflow for PyPI trusted publishing on main
- Add license, classifiers, keywords, and URLs to pyproject.toml
- Fix ruff lint errors (unused imports, duplicate class name) and formatting
2026-04-23 18:32:59 +06:00
ad64c85393 Merge pull request 'Feat: Added git support' (#5) from feat/git-support into dev
Some checks failed
ci/woodpecker/push/check Pipeline failed
Reviewed-on: #5
2026-04-22 23:45:36 +00:00
bab53aedbe Updated readme 2026-04-23 05:44:49 +06:00
82e181dd7e test: add integration tests for capsule lifecycle, commands, files, and git
43 tests across 4 classes hitting the live API. Shared capsule per class
to minimize VM boot overhead. All capsules destroyed in teardown.
Skips automatically when WRENN_API_KEY is not available.
2026-04-23 05:40:06 +06:00
ee1f55635f fix: wrap commands in /bin/sh -c for proper server-side argv expansion
The server-side agent runs commands through a nice wrapper that uses
"${@}" expansion. Sending the full command string as a single cmd field
caused nice to treat it as one executable name. Now Commands.run sends
cmd=/bin/sh args=["-c", cmd_string] so "${@}" expands into proper argv.
2026-04-23 05:16:08 +06:00
6bdf28e2ae Added git integration 2026-04-23 04:46:57 +06:00
61bc040098 Minor patches
Some checks failed
ci/woodpecker/push/check Pipeline failed
2026-04-23 02:31:47 +06:00
7b35ffb60c docs: add Google-style docstrings to all public SDK methods
Some checks failed
ci/woodpecker/push/check Pipeline failed
2026-04-17 04:29:34 +06:00
42bcc792d6 Updated dependency
Some checks failed
ci/woodpecker/push/check Pipeline failed
2026-04-17 03:29:45 +06:00
3f97c73b2f feat: redesign code interpreter with structured Execution model
Some checks failed
ci/woodpecker/push/check Pipeline failed
Replace flat CodeResult with a proper model hierarchy: Execution
(top-level), Result (per-output with typed MIME fields), Logs
(stdout/stderr as lists), and ExecutionError (structured
name/value/traceback). Handle display_data messages for rich output,
add streaming callbacks (on_result, on_stdout, on_stderr, on_error),
and remove the misleading stdout-to-text fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 03:16:39 +06:00
7e7ecbd48a Merge pull request 'feat: implement client architecture and sandbox environment' (#3) from feat/client-and-sandbox-support into dev
Some checks failed
ci/woodpecker/push/check Pipeline failed
Reviewed-on: #3
2026-04-15 15:35:40 +00:00
7b9a06d1b5 chore: add python-dotenv dependency
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:33:53 +06:00
3d0eda5c60 feat: rename kill to destroy, improve code interpreter, update README
- Rename Capsule.kill/AsyncCapsule.kill to destroy for frontend consistency
- Add Sandbox deprecation alias to wrenn.code_interpreter module
- run_code text falls back to stripped stdout when no expression result
- Strip quotes from string expression results (matching e2b behavior)
- _ensure_kernel reuses existing Jupyter kernels before creating new ones
- Rewrite README with complete examples for capsules and code interpreter
- Remove stale AGENTS.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 18:58:59 +06:00
eecf1dc65b chore: update OpenAPI schema, generated models, and build config
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:31:07 +06:00
3cced768a4 feat: redesign SDK with e2b-compatible interface
Replace the WrennClient-centric API with a top-level Capsule class that
mirrors e2b's Sandbox interface, enabling drop-in migration. Key changes:

- Capsule/AsyncCapsule with direct construction (reads WRENN_API_KEY and
  WRENN_BASE_URL env vars), namespaced sub-objects (capsule.commands,
  capsule.files), dual instance/static lifecycle methods via _DualMethod
  descriptor (capsule.kill() and Capsule.kill(id))
- WrennClient simplified to API-key-only endpoints (capsules, snapshots);
  JWT-based resources (auth, hosts, teams) removed
- wrenn.code_interpreter submodule with Capsule subclass defaulting to
  code-runner-beta template and run_code() support
- Sandbox alias emits FutureWarning instead of DeprecationWarning

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:19:23 +06:00
0ac9bf79ee feat: created README 2026-04-13 03:16:44 +06:00
bf5914c0a8 fix: renamed sandbox to capsule 2026-04-13 03:16:27 +06:00
976af9a209 ci: woodpecker doesn't support variable expansions outside of commands 2026-04-12 03:08:34 +06:00
f3fd6865f9 ci: bug fixes 2026-04-12 03:03:33 +06:00
340ed46df6 CI for linting and testing 2026-04-12 02:51:14 +06:00
a5bf66c199 feat: add sandbox filesystem and terminal support
Add sandbox filesystem methods (list_dir, mkdir, remove, upload,
download, stream_upload, stream_download) and interactive PTY sessions
(PtySession, AsyncPtySession) with reconnect support per
FILE_TERMINAL.md spec. Refactor error handling into exceptions.py as
shared handle_response(). Replace API-key-only proxy auth with unified
_proxy_headers() supporting both API key and JWT. Fix stream_upload to
build multipart manually instead of relying on httpx files= with
generators. Switch Makefile SPEC_URL from main to dev branch. Regenerate
models from updated OpenAPI spec (adds teams, channels, metrics, PTY
endpoints). Add comprehensive unit and integration tests. Trim AGENTS.md
to verified facts only.
2026-04-12 02:35:20 +06:00
f51a962fff feat: implement client architecture and sandbox environment
Introduces the core Wrenn client and a dedicated sandbox execution
environment. This includes automated model generation and a custom
exception hierarchy to support robust integration.

- Add `WrennClient` in `src/wrenn/client.py` for API interaction.
- Implement `Sandbox` in `src/wrenn/sandbox.py` for isolated execution.
- Add Pydantic/model support via `_generated.py`.
- Define project-specific error types in `exceptions.py`.
- Include AGENTS.md documentation for specialized logic.
- Add comprehensive unit and integration tests.
- Update build system (Makefile, uv.lock, pyproject.toml) and LICENSE.
2026-04-10 22:24:50 +06:00
10 changed files with 379 additions and 816 deletions

View File

@ -172,6 +172,8 @@ import sys
# Stream a new command # Stream a new command
for event in capsule.commands.stream("python", args=["-u", "train.py"]): for event in capsule.commands.stream("python", args=["-u", "train.py"]):
match event.type: match event.type:
case "start":
print(f"PID: {event.pid}")
case "stdout": case "stdout":
print(event.data, end="") print(event.data, end="")
case "stderr": case "stderr":
@ -181,7 +183,10 @@ for event in capsule.commands.stream("python", args=["-u", "train.py"]):
# Connect to a running background process # Connect to a running background process
for event in capsule.commands.connect(handle.pid): for event in capsule.commands.connect(handle.pid):
if event.type == "stdout": match event.type:
case "start":
print(f"PID: {event.pid}")
case "stdout":
print(event.data, end="") print(event.data, end="")
``` ```
@ -211,6 +216,7 @@ capsule.files.exists("/app/main.py") # True
# List directory # List directory
entries = capsule.files.list("/home/user", depth=1) entries = capsule.files.list("/home/user", depth=1)
# FileEntry has: name, type (file/dir), size, modified_at
for entry in entries: for entry in entries:
print(entry.name, entry.type, entry.size) 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") capsule.git.remote_add("upstream", "https://github.com/org/repo.git", cwd="/app")
url = capsule.git.remote_get("origin", cwd="/app") # str | None 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`: Git errors raise `GitCommandError` (or `GitAuthError` for authentication failures), both inheriting from `GitError`:
```python ```python
@ -308,7 +333,7 @@ except GitAuthError as e:
```python ```python
import sys 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") term.write(b"ls -la\n")
for event in term: for event in term:
if event.type == "output": 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 | | `logs` | `Logs` | `.stdout: list[str]` and `.stderr: list[str]` chunks |
| `error` | `ExecutionError \| None` | `.name`, `.value`, `.traceback` | | `error` | `ExecutionError \| None` | `.name`, `.value`, `.traceback` |
| `execution_count` | `int \| None` | Jupyter cell execution counter | | `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` | | `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 ### Code Runner + Commands/Files
@ -533,8 +559,8 @@ from wrenn import (
WrennNotFoundError, # 404 WrennNotFoundError, # 404
WrennConflictError, # 409 WrennConflictError, # 409
WrennHostHasCapsulesError, # 409 (host has running capsules) WrennHostHasCapsulesError, # 409 (host has running capsules)
WrennAgentError, # 502
WrennInternalError, # 500 WrennInternalError, # 500
WrennAgentError, # 502
WrennHostUnavailableError, # 503 WrennHostUnavailableError, # 503
) )
@ -603,7 +629,7 @@ with WrennClient(api_key="wrn_...") as client:
# Snapshots # Snapshots
template = client.snapshots.create(capsule_id="cl-abc", name="my-snap") 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") client.snapshots.delete("my-snap")
``` ```

View File

@ -1421,10 +1421,19 @@ paths:
- apiKeyAuth: [] - apiKeyAuth: []
- sessionAuth: [] - sessionAuth: []
description: | description: |
Live snapshot: briefly pauses the capsule, writes its VM state + Snapshot a capsule, processed asynchronously. The call returns
memory + flattened rootfs to a new template directory, then resumes immediately with the capsule in the `snapshotting` state, then it
the capsule. The source capsule keeps running after the snapshot; returns to its original state on completion. The capsule must be
the resulting template can be used to create new capsules. `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 Snapshots are immutable: each call must use a fresh name. Re-using
an existing name returns 409 Conflict. an existing name returns 409 Conflict.
@ -1435,14 +1444,14 @@ paths:
schema: schema:
$ref: "#/components/schemas/CreateSnapshotRequest" $ref: "#/components/schemas/CreateSnapshotRequest"
responses: responses:
"201": "202":
description: Snapshot created description: Snapshot accepted; capsule is now snapshotting
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Template" $ref: "#/components/schemas/Capsule"
"409": "409":
description: Name already exists or capsule not running description: Name already exists, or capsule is not running or paused
content: content:
application/json: application/json:
schema: schema:
@ -2707,14 +2716,39 @@ paths:
tags: [admin] tags: [admin]
security: security:
- sessionAuth: [] - sessionAuth: []
parameters:
- name: page
in: query
required: false
schema:
type: integer
minimum: 1
default: 1
description: Page number for pagination.
responses: responses:
"200": "200":
description: Teams list description: Paginated teams list
content: content:
application/json: application/json:
schema: schema:
type: object
properties:
teams:
type: array type: array
items: {type: object} 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: /v1/admin/teams/{id}/byoc:
put: put:
@ -2734,12 +2768,20 @@ paths:
application/json: application/json:
schema: schema:
type: object type: object
required: [byoc] required: [enabled]
properties: properties:
byoc: {type: boolean} enabled:
type: boolean
description: true to enable BYOC, false to disable.
responses: responses:
"204": "204":
description: Updated description: Updated
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
/v1/admin/teams/{id}: /v1/admin/teams/{id}:
delete: delete:
@ -2756,6 +2798,38 @@ paths:
responses: responses:
"204": "204":
description: Deleted 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: /v1/admin/users:
get: get:
@ -2813,7 +2887,7 @@ paths:
schema: schema:
type: array type: array
items: items:
$ref: "#/components/schemas/Template" $ref: "#/components/schemas/AdminTemplate"
/v1/admin/templates/{name}: /v1/admin/templates/{name}:
delete: delete:
@ -2899,6 +2973,26 @@ paths:
"204": "204":
description: Cancelled 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: /v1/admin/capsules:
post: post:
summary: Create a capsule on behalf of any team (admin) summary: Create a capsule on behalf of any team (admin)
@ -2969,6 +3063,10 @@ paths:
summary: Create snapshot from any capsule (admin) summary: Create snapshot from any capsule (admin)
operationId: adminCreateSnapshotFromCapsule operationId: adminCreateSnapshotFromCapsule
tags: [admin] 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: security:
- sessionAuth: [] - sessionAuth: []
parameters: parameters:
@ -2977,21 +3075,22 @@ paths:
required: true required: true
schema: {type: string} schema: {type: string}
requestBody: requestBody:
required: true required: false
content: content:
application/json: application/json:
schema: schema:
type: object type: object
required: [name]
properties: properties:
name: {type: string} name:
type: string
description: Optional; an auto-generated name is used when omitted.
responses: responses:
"201": "202":
description: Snapshot created description: Snapshot accepted; capsule is now snapshotting
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Template" $ref: "#/components/schemas/Capsule"
/v1/admin/capsules/{id}/exec: /v1/admin/capsules/{id}/exec:
parameters: parameters:
@ -3486,7 +3585,7 @@ components:
properties: properties:
template: template:
type: string type: string
default: minimal default: minimal-ubuntu
vcpus: vcpus:
type: integer type: integer
default: 1 default: 1
@ -3547,10 +3646,6 @@ components:
type: integer type: integer
memory_mb_reserved: memory_mb_reserved:
type: integer type: integer
sampled_at:
type: string
format: date-time
nullable: true
peaks: peaks:
type: object type: object
description: Maximum values over the last 30 days. description: Maximum values over the last 30 days.
@ -3590,7 +3685,7 @@ components:
type: string type: string
status: status:
type: string 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: template:
type: string type: string
vcpus: vcpus:
@ -3599,10 +3694,6 @@ components:
type: integer type: integer
timeout_sec: timeout_sec:
type: integer type: integer
guest_ip:
type: string
host_ip:
type: string
created_at: created_at:
type: string type: string
format: date-time format: date-time
@ -3627,7 +3718,11 @@ components:
agent_version, envd_version) when running. agent_version, envd_version) when running.
disk_size_mb: disk_size_mb:
type: integer 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: CreateSnapshotRequest:
type: object type: object
@ -3664,13 +3759,51 @@ components:
type: boolean type: boolean
description: | description: |
True when the template is platform-managed (visible to all teams, 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. 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: metadata:
type: object type: object
additionalProperties: {type: string} additionalProperties: {type: string}
nullable: true 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: ExecRequest:
type: object type: object
required: [cmd] required: [cmd]
@ -3941,6 +4074,25 @@ components:
updated_at: updated_at:
type: string type: string
format: date-time 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: RefreshHostTokenRequest:
type: object type: object
@ -4052,6 +4204,39 @@ components:
items: items:
$ref: "#/components/schemas/TeamMember" $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: CapsuleMetrics:
type: object type: object
properties: properties:

View File

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

View File

@ -164,4 +164,17 @@ def __getattr__(name: str) -> type:
stacklevel=2, stacklevel=2,
) )
return WrennHostHasCapsulesError 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}") raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

View File

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

View File

@ -1,153 +1,20 @@
# generated by datamodel-codegen: # generated by datamodel-codegen:
# filename: openapi.yaml # filename: openapi.yaml
# timestamp: 2026-05-19T08:54:50+00:00 # timestamp: 2026-05-23T11:20:02+00:00
from __future__ import annotations from __future__ import annotations
from pydantic import AwareDatetime, BaseModel, EmailStr, Field from pydantic import AwareDatetime, BaseModel, Field
from typing import Annotated, Any from typing import Annotated
from datetime import date as date_aliased
from enum import StrEnum 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): class Status(StrEnum):
pending = "pending" pending = "pending"
starting = "starting" starting = "starting"
running = "running" running = "running"
pausing = "pausing" pausing = "pausing"
paused = "paused" paused = "paused"
snapshotting = "snapshotting"
resuming = "resuming" resuming = "resuming"
stopping = "stopping" stopping = "stopping"
hibernated = "hibernated" hibernated = "hibernated"
@ -163,8 +30,6 @@ class Capsule(BaseModel):
vcpus: int | None = None vcpus: int | None = None
memory_mb: int | None = None memory_mb: int | None = None
timeout_sec: int | None = None timeout_sec: int | None = None
guest_ip: str | None = None
host_ip: str | None = None
created_at: AwareDatetime | None = None created_at: AwareDatetime | None = None
started_at: AwareDatetime | None = None started_at: AwareDatetime | None = None
last_active_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" 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 ] = None
disk_size_mb: int | None = None disk_size_mb: Annotated[
int | None, Field(description="Maximum disk capacity in MiB.")
] = None
class CreateSnapshotRequest(BaseModel): disk_used_mb: Annotated[
sandbox_id: Annotated[ int | None,
str, Field(description="ID of the running capsule to snapshot.") Field(
] description="Current disk usage in MiB. Only populated on individual capsule GET; omitted in list responses."
name: Annotated[ ),
str | None,
Field(description="Name for the snapshot template. Auto-generated if omitted."),
] = None ] = None
@ -203,100 +66,19 @@ class Template(BaseModel):
platform: Annotated[ platform: Annotated[
bool | None, bool | None,
Field( 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 ] = None
metadata: dict[str, str] | None = None metadata: dict[str, str] | None = None
class ExecRequest(BaseModel): class Type2(StrEnum):
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):
file = "file" file = "file"
directory = "directory" directory = "directory"
symlink = "symlink" symlink = "symlink"
@ -305,7 +87,7 @@ class Type1(StrEnum):
class FileEntry(BaseModel): class FileEntry(BaseModel):
name: str | None = None name: str | None = None
path: str | None = None path: str | None = None
type: Type1 | None = None type: Type2 | None = None
size: int | None = None size: int | None = None
mode: int | None = None mode: int | None = None
permissions: Annotated[ permissions: Annotated[
@ -319,438 +101,9 @@ class FileEntry(BaseModel):
symlink_target: str | None = None symlink_target: str | None = None
class MakeDirRequest(BaseModel):
path: Annotated[
str, Field(description="Directory path to create inside the capsule")
]
class MakeDirResponse(BaseModel): class MakeDirResponse(BaseModel):
entry: FileEntry | None = None 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): class ListDirResponse(BaseModel):
entries: list[FileEntry] | None = None 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": [ "entries": [
{ {
"name": "main.py", "name": "main.py",
"path": "/home/user/main.py", "path": "/home/wrenn-user/main.py",
"type": "file", "type": "file",
"size": 1024, "size": 1024,
"mode": 33188, "mode": 33188,
"permissions": "-rw-r--r--", "permissions": "-rw-r--r--",
"owner": "root", "owner": "wrenn-user",
"group": "root", "group": "wrenn-user",
"modified_at": 1712899200, "modified_at": 1712899200,
"symlink_target": None, "symlink_target": None,
}, },
{ {
"name": "config", "name": "config",
"path": "/home/user/config", "path": "/home/wrenn-user/config",
"type": "directory", "type": "directory",
"size": 4096, "size": 4096,
"mode": 16877, "mode": 16877,
"permissions": "drwxr-xr-x", "permissions": "drwxr-xr-x",
"owner": "root", "owner": "wrenn-user",
"group": "root", "group": "wrenn-user",
"modified_at": 1712899100, "modified_at": 1712899100,
"symlink_target": None, "symlink_target": None,
}, },
] ]
}, },
) )
entries = cap.files.list("/home/user") entries = cap.files.list("/home/wrenn-user")
assert len(entries) == 2 assert len(entries) == 2
assert isinstance(entries[0], FileEntry) assert isinstance(entries[0], FileEntry)
assert entries[0].name == "main.py" assert entries[0].name == "main.py"
@ -113,7 +113,7 @@ class TestFilesList:
route = respx.post(f"{BASE}/v1/capsules/cl-abc/files/list").respond( route = respx.post(f"{BASE}/v1/capsules/cl-abc/files/list").respond(
200, json={"entries": []} 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) body = json.loads(route.calls[0].request.content)
assert body["depth"] == 3 assert body["depth"] == 3
@ -136,19 +136,19 @@ class TestFilesMakeDir:
json={ json={
"entry": { "entry": {
"name": "data", "name": "data",
"path": "/home/user/data", "path": "/home/wrenn-user/data",
"type": "directory", "type": "directory",
"size": 4096, "size": 4096,
"mode": 16877, "mode": 16877,
"permissions": "drwxr-xr-x", "permissions": "drwxr-xr-x",
"owner": "root", "owner": "wrenn-user",
"group": "root", "group": "wrenn-user",
"modified_at": 1712899200, "modified_at": 1712899200,
"symlink_target": None, "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 isinstance(entry, FileEntry)
assert entry.name == "data" assert entry.name == "data"
assert entry.type == "directory" assert entry.type == "directory"
@ -166,20 +166,20 @@ class TestFilesMakeDir:
"entries": [ "entries": [
{ {
"name": "data", "name": "data",
"path": "/home/user/data", "path": "/home/wrenn-user/data",
"type": "directory", "type": "directory",
"size": 4096, "size": 4096,
"mode": 16877, "mode": 16877,
"permissions": "drwxr-xr-x", "permissions": "drwxr-xr-x",
"owner": "root", "owner": "wrenn-user",
"group": "root", "group": "wrenn-user",
"modified_at": 1712899200, "modified_at": 1712899200,
"symlink_target": None, "symlink_target": None,
} }
] ]
}, },
) )
entry = cap.files.make_dir("/home/user/data") entry = cap.files.make_dir("/home/wrenn-user/data")
assert entry.name == "data" assert entry.name == "data"
@ -188,7 +188,7 @@ class TestFilesRemove:
def test_remove_succeeds(self): def test_remove_succeeds(self):
cap = _make_capsule() cap = _make_capsule()
route = respx.post(f"{BASE}/v1/capsules/cl-abc/files/remove").respond(204) 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 assert route.called
@respx.mock @respx.mock
@ -411,7 +411,7 @@ class TestPtySessionSendStart:
cols=120, cols=120,
rows=40, rows=40,
envs={"TERM": "xterm-256color"}, envs={"TERM": "xterm-256color"},
cwd="/home/user", cwd="/home/wrenn-user",
) )
sent = json.loads(ws.send_text.call_args[0][0]) sent = json.loads(ws.send_text.call_args[0][0])
assert sent["cmd"] == "/bin/zsh" assert sent["cmd"] == "/bin/zsh"

View File

@ -323,7 +323,7 @@ class TestFiles:
class TestGit: class TestGit:
"""Shared capsule for git operation tests. """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. does not support the cwd parameter.
""" """
@ -344,14 +344,14 @@ class TestGit:
pass pass
def test_init_created_repo(self): 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): def test_status_clean(self):
status = self.capsule.git.status() status = self.capsule.git.status()
assert status.branch == "main" assert status.branch == "main"
def test_add_and_commit(self): 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) self.capsule.git.add(all=True)
result = self.capsule.git.commit("initial commit") result = self.capsule.git.commit("initial commit")
assert result.exit_code == 0 assert result.exit_code == 0
@ -361,14 +361,14 @@ class TestGit:
assert status.is_clean assert status.is_clean
def test_status_with_changes(self): 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: try:
status = self.capsule.git.status() status = self.capsule.git.status()
assert not status.is_clean assert not status.is_clean
paths = [f.path for f in status.files] paths = [f.path for f in status.files]
assert "dirty.txt" in paths assert "dirty.txt" in paths
finally: finally:
self.capsule.files.remove("/root/dirty.txt") self.capsule.files.remove("/home/wrenn-user/dirty.txt")
def test_branches(self): def test_branches(self):
branches = self.capsule.git.branches() branches = self.capsule.git.branches()

View File

@ -75,7 +75,7 @@ class TestCommandEnvironment:
def test_default_cwd_is_home(self): def test_default_cwd_is_home(self):
result = self.capsule.commands.run("pwd") 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): def test_cwd_resolves_relative_paths(self):
self.capsule.files.make_dir("/tmp/cwd_probe/sub") 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. # Each run is a fresh process — `cd` in one does not affect the next.
self.capsule.commands.run("cd /tmp") self.capsule.commands.run("cd /tmp")
result = self.capsule.commands.run("pwd") result = self.capsule.commands.run("pwd")
assert result.stdout.strip() == "/root" assert result.stdout.strip() == "/home/wrenn-user"
def test_single_env_var(self): def test_single_env_var(self):
result = self.capsule.commands.run("echo $GREETING", envs={"GREETING": "hi"}) result = self.capsule.commands.run("echo $GREETING", envs={"GREETING": "hi"})
@ -115,9 +115,29 @@ class TestCommandEnvironment:
def test_base_environment_present(self): def test_base_environment_present(self):
result = self.capsule.commands.run("echo $HOME; echo $PATH") result = self.capsule.commands.run("echo $HOME; echo $PATH")
lines = result.stdout.strip().splitlines() lines = result.stdout.strip().splitlines()
assert lines[0] == "/root" assert lines[0] == "/home/wrenn-user"
assert "/usr/bin" in lines[1] 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 # Long-running commands
@ -143,7 +163,7 @@ class TestLongRunningCommands:
def test_apt_get_install(self): def test_apt_get_install(self):
result = self.capsule.commands.run( 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 assert result.exit_code == 0
@ -388,7 +408,9 @@ class TestGitClone:
def setup_class(cls): def setup_class(cls):
_ensure_env() _ensure_env()
cls.capsule = Capsule(wait=True) 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 @classmethod
def teardown_class(cls): def teardown_class(cls):
@ -398,66 +420,74 @@ class TestGitClone:
pass pass
def test_clone_created_repo(self): 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): 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] names = [e.name for e in entries]
assert "README.md" in names assert "README.md" in names
def test_status_of_clone_is_clean(self): 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.branch == "main"
assert status.is_clean assert status.is_clean
def test_branches_lists_main(self): 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] names = [b.name for b in branches]
assert "main" in names assert "main" in names
assert any(b.is_current for b in branches) assert any(b.is_current for b in branches)
def test_remote_get_origin(self): 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 url is not None
assert "wrennhq/wrenn" in url assert "wrennhq/wrenn" in url
def test_git_log_has_commit(self): 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.exit_code == 0
assert result.stdout.strip() assert result.stdout.strip()
def test_modify_add_commit(self): def test_modify_add_commit(self):
marker = uuid.uuid4().hex marker = uuid.uuid4().hex
self.capsule.git.configure_user( 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.files.write(
self.capsule.git.add([f"sdk_probe_{marker}.txt"], cwd="/root/wrenn") 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 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 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.is_clean
assert after.ahead >= 1 assert after.ahead >= 1
def test_create_and_checkout_branch_in_clone(self): def test_create_and_checkout_branch_in_clone(self):
self.capsule.git.create_branch("sdk-feature", cwd="/root/wrenn") self.capsule.git.create_branch("sdk-feature", cwd="/home/wrenn-user/wrenn")
branches = self.capsule.git.branches(cwd="/root/wrenn") branches = self.capsule.git.branches(cwd="/home/wrenn-user/wrenn")
current = [b for b in branches if b.is_current] current = [b for b in branches if b.is_current]
assert current and current[0].name == "sdk-feature" 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): 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: 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 assert "README.md" in result.stdout
finally: 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: class TestGitErrors:
@ -481,7 +511,7 @@ class TestGitErrors:
with pytest.raises(GitError): with pytest.raises(GitError):
self.capsule.git.clone( self.capsule.git.clone(
"https://github.com/wrennhq/this-repo-does-not-exist-xyz", "https://github.com/wrennhq/this-repo-does-not-exist-xyz",
"/root/missing", "/home/wrenn-user/missing",
timeout=120, timeout=120,
) )
@ -493,7 +523,11 @@ class TestGitErrors:
def test_clone_with_branch(self): def test_clone_with_branch(self):
self.capsule.git.clone( 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" assert status.branch == "main"

2
uv.lock generated
View File

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