Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Runnable examples live in [`examples/`](./examples).
- [Devbox From Blueprint (Run Command, Shutdown)](#devbox-from-blueprint-lifecycle)
- [Devbox Snapshot and Resume](#devbox-snapshot-resume)
- [MCP Hub + Claude Code + GitHub](#mcp-github-tools)
- [Secrets with Devbox (Create, Inject, Verify, Delete)](#secrets-with-devbox)

<a id="blueprint-with-build-context"></a>
## Blueprint with Build Context
Expand Down Expand Up @@ -135,3 +136,34 @@ uv run pytest -m smoketest tests/smoketests/examples/
```

**Source:** [`examples/mcp_github_tools.py`](./examples/mcp_github_tools.py)

<a id="secrets-with-devbox"></a>
## Secrets with Devbox (Create, Inject, Verify, Delete)

**Use case:** Create a secret, inject it into a devbox as an environment variable, verify access, and clean up.

**Tags:** `secrets`, `devbox`, `environment-variables`, `cleanup`

### Workflow
- Create a secret with a test value
- Create a devbox with the secret mapped to an env var
- Execute a command that reads the secret from the environment
- Verify the value matches
- Update the secret and verify
- List secrets and verify the secret appears
- Shutdown devbox and delete secret

### Prerequisites
- `RUNLOOP_API_KEY`

### Run
```sh
uv run python -m examples.secrets_with_devbox
```

### Test
```sh
uv run pytest -m smoketest tests/smoketests/examples/
```

**Source:** [`examples/secrets_with_devbox.py`](./examples/secrets_with_devbox.py)
1 change: 1 addition & 0 deletions README-SDK.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ The SDK provides object-oriented interfaces for all major Runloop resources:
- **`runloop.blueprint`** - Blueprint management (create, list, build blueprints)
- **`runloop.snapshot`** - Snapshot management (list disk snapshots)
- **`runloop.storage_object`** - Storage object management (upload, download, list objects)
- **`runloop.secret`** - Secret management (create, update, list, delete encrypted key-value pairs)
- **`runloop.api`** - Direct access to the underlying REST API client

### Devbox
Expand Down
4 changes: 2 additions & 2 deletions examples/mcp_github_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,9 @@ def recipe(ctx: RecipeContext, options: McpExampleOptions) -> RecipeOutput: # n

# Store the GitHub PAT as a Runloop secret
secret_name = unique_name("example-github-mcp")
sdk.api.secrets.create(name=secret_name, value=github_token)
secret = sdk.secret.create(name=secret_name, value=github_token)
resources_created.append(f"secret:{secret_name}")
cleanup.add(f"secret:{secret_name}", lambda: sdk.api.secrets.delete(secret_name))
cleanup.add(f"secret:{secret_name}", secret.delete)

# Launch a devbox with MCP Hub wiring
devbox = sdk.devbox.create(
Expand Down
8 changes: 8 additions & 0 deletions examples/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from .example_types import ExampleResult
from .mcp_github_tools import run_mcp_github_tools_example
from .secrets_with_devbox import run_secrets_with_devbox_example
from .devbox_snapshot_resume import run_devbox_snapshot_resume_example
from .blueprint_with_build_context import run_blueprint_with_build_context_example
from .devbox_from_blueprint_lifecycle import run_devbox_from_blueprint_lifecycle_example
Expand Down Expand Up @@ -44,6 +45,13 @@
"required_env": ["RUNLOOP_API_KEY", "GITHUB_TOKEN", "ANTHROPIC_API_KEY"],
"run": run_mcp_github_tools_example,
},
{
"slug": "secrets-with-devbox",
"title": "Secrets with Devbox (Create, Inject, Verify, Delete)",
"file_name": "secrets_with_devbox.py",
"required_env": ["RUNLOOP_API_KEY"],
"run": run_secrets_with_devbox_example,
},
]


Expand Down
112 changes: 112 additions & 0 deletions examples/secrets_with_devbox.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
#!/usr/bin/env -S uv run python
"""
---
title: Secrets with Devbox (Create, Inject, Verify, Delete)
slug: secrets-with-devbox
use_case: Create a secret, inject it into a devbox as an environment variable, verify access, and clean up.
workflow:
- Create a secret with a test value
- Create a devbox with the secret mapped to an env var
- Execute a command that reads the secret from the environment
- Verify the value matches
- Update the secret and verify
- List secrets and verify the secret appears
- Shutdown devbox and delete secret
tags:
- secrets
- devbox
- environment-variables
- cleanup
prerequisites:
- RUNLOOP_API_KEY
run: uv run python -m examples.secrets_with_devbox
test: uv run pytest -m smoketest tests/smoketests/examples/
---
"""

from __future__ import annotations

from runloop_api_client import RunloopSDK

from ._harness import run_as_cli, unique_name, wrap_recipe
from .example_types import ExampleCheck, RecipeOutput, RecipeContext

# Note: do NOT hardcode secret values in your code!
# This is example code only; use environment variables instead!
_EXAMPLE_SECRET_VALUE = "my-secret-value"
_UPDATED_SECRET_VALUE = "updated-secret-value"


def recipe(ctx: RecipeContext) -> RecipeOutput:
"""Create a secret, inject it into a devbox, and verify it is accessible."""
cleanup = ctx.cleanup

sdk = RunloopSDK()
resources_created: list[str] = []
checks: list[ExampleCheck] = []

secret_name = unique_name("RUNLOOP_SDK_EXAMPLE").upper().replace("-", "_")

secret = sdk.secret.create(name=secret_name, value=_EXAMPLE_SECRET_VALUE)
resources_created.append(f"secret:{secret_name}")
cleanup.add(f"secret:{secret_name}", lambda: secret.delete())

secret_info = secret.get_info()
checks.append(
ExampleCheck(
name="secret created successfully",
passed=secret.name == secret_name and secret_info.id.startswith("sec_"),
details=f"name={secret.name}, id={secret_info.id}",
)
)

devbox = sdk.devbox.create(
name=unique_name("secrets-example-devbox"),
secrets={
"MY_SECRET_ENV": secret.name,
},
launch_parameters={
"resource_size_request": "X_SMALL",
"keep_alive_time_seconds": 60 * 5,
},
)
resources_created.append(f"devbox:{devbox.id}")
cleanup.add(f"devbox:{devbox.id}", devbox.shutdown)

result = devbox.cmd.exec("echo $MY_SECRET_ENV")
stdout = result.stdout().strip()
checks.append(
ExampleCheck(
name="devbox can read secret as env var",
passed=result.exit_code == 0 and stdout == _EXAMPLE_SECRET_VALUE,
details=f'exit_code={result.exit_code}, stdout="{stdout}"',
)
)

updated_info = sdk.secret.update(secret, _UPDATED_SECRET_VALUE).get_info()
checks.append(
ExampleCheck(
name="secret updated successfully",
passed=updated_info.name == secret_name,
details=f"update_time_ms={updated_info.update_time_ms}",
)
)

secrets = sdk.secret.list()
found = next((s for s in secrets if s.name == secret_name), None)
checks.append(
ExampleCheck(
name="secret appears in list",
passed=found is not None,
details=f"found name={found.name}" if found else "not found",
)
)

return RecipeOutput(resources_created=resources_created, checks=checks)


run_secrets_with_devbox_example = wrap_recipe(recipe)


if __name__ == "__main__":
run_as_cli(run_secrets_with_devbox_example)
3 changes: 2 additions & 1 deletion llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
- [Devbox lifecycle example](examples/devbox_from_blueprint_lifecycle.py): Create blueprint, launch devbox, run commands, cleanup
- [Devbox snapshot and resume example](examples/devbox_snapshot_resume.py): Snapshot disk, resume from snapshot, verify state isolation
- [MCP GitHub example](examples/mcp_github_tools.py): MCP Hub integration with Claude Code
- [Secrets with Devbox example](examples/secrets_with_devbox.py): Create secret, inject into devbox, verify, cleanup

## API Reference

Expand All @@ -23,7 +24,7 @@

- **Prefer `AsyncRunloopSDK` over `RunloopSDK`** for better concurrency and performance; all SDK methods have async equivalents
- Use `async with await runloop.devbox.create()` for automatic cleanup via context manager
- For resources without SDK coverage (e.g., secrets, benchmarks), use `runloop.api.*` as a fallback
- For resources without SDK coverage (e.g., benchmarks), use `runloop.api.*` as a fallback
- Use `await devbox.cmd.exec('command')` for commands expected to return immediately (e.g., `echo`, `pwd`, `cat`)—blocks until completion, returns `ExecutionResult` with stdout/stderr
- Use `await devbox.cmd.exec_async('command')` for long-running or background processes (servers, watchers, builds)—returns immediately with `Execution` handle to check status, get result, or kill
- Both `exec` and `exec_async` support streaming callbacks (`stdout`, `stderr`, `output`) for real-time output
Expand Down
13 changes: 12 additions & 1 deletion scripts/mock
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,22 @@ echo "==> Starting mock server with URL ${URL}"

# Run prism mock on the given spec
if [ "$1" == "--daemon" ]; then
# Pre-install the package so the download doesn't eat into the startup timeout
npm exec --package=@stainless-api/[email protected] -- prism --version

npm exec --package=@stainless-api/[email protected] -- prism mock "$URL" &> .prism.log &

# Wait for server to come online
# Wait for server to come online (max 30s)
echo -n "Waiting for server"
attempts=0
while ! grep -q "✖ fatal\|Prism is listening" ".prism.log" ; do
attempts=$((attempts + 1))
if [ "$attempts" -ge 300 ]; then
echo
echo "Timed out waiting for Prism server to start"
cat .prism.log
exit 1
fi
echo -n "."
sleep 0.1
done
Expand Down
78 changes: 78 additions & 0 deletions src/runloop_api_client/resources/secrets.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,39 @@ def create(
cast_to=SecretView,
)

def retrieve(
self,
name: str,
*,
extra_headers: Headers | None = None,
extra_query: Query | None = None,
extra_body: Body | None = None,
timeout: float | httpx.Timeout | None | NotGiven = not_given,
) -> SecretView:
"""Retrieve a Secret by name.

Args:
extra_headers: Send extra headers

extra_query: Add additional query parameters to the request

extra_body: Add additional JSON properties to the request

timeout: Override the client-level default timeout for this request, in seconds
"""
if not name:
raise ValueError(f"Expected a non-empty value for `name` but received {name!r}")
return self._get(
f"/v1/secrets/{name}",
options=make_request_options(
extra_headers=extra_headers,
extra_query=extra_query,
extra_body=extra_body,
timeout=timeout,
),
cast_to=SecretView,
)

def update(
self,
name: str,
Expand Down Expand Up @@ -299,6 +332,39 @@ async def create(
cast_to=SecretView,
)

async def retrieve(
self,
name: str,
*,
extra_headers: Headers | None = None,
extra_query: Query | None = None,
extra_body: Body | None = None,
timeout: float | httpx.Timeout | None | NotGiven = not_given,
) -> SecretView:
"""Retrieve a Secret by name.

Args:
extra_headers: Send extra headers

extra_query: Add additional query parameters to the request

extra_body: Add additional JSON properties to the request

timeout: Override the client-level default timeout for this request, in seconds
"""
if not name:
raise ValueError(f"Expected a non-empty value for `name` but received {name!r}")
return await self._get(
f"/v1/secrets/{name}",
options=make_request_options(
extra_headers=extra_headers,
extra_query=extra_query,
extra_body=extra_body,
timeout=timeout,
),
cast_to=SecretView,
)

async def update(
self,
name: str,
Expand Down Expand Up @@ -435,6 +501,9 @@ def __init__(self, secrets: SecretsResource) -> None:
self.create = to_raw_response_wrapper(
secrets.create,
)
self.retrieve = to_raw_response_wrapper(
secrets.retrieve,
)
self.update = to_raw_response_wrapper(
secrets.update,
)
Expand All @@ -453,6 +522,9 @@ def __init__(self, secrets: AsyncSecretsResource) -> None:
self.create = async_to_raw_response_wrapper(
secrets.create,
)
self.retrieve = async_to_raw_response_wrapper(
secrets.retrieve,
)
self.update = async_to_raw_response_wrapper(
secrets.update,
)
Expand All @@ -471,6 +543,9 @@ def __init__(self, secrets: SecretsResource) -> None:
self.create = to_streamed_response_wrapper(
secrets.create,
)
self.retrieve = to_streamed_response_wrapper(
secrets.retrieve,
)
self.update = to_streamed_response_wrapper(
secrets.update,
)
Expand All @@ -489,6 +564,9 @@ def __init__(self, secrets: AsyncSecretsResource) -> None:
self.create = async_to_streamed_response_wrapper(
secrets.create,
)
self.retrieve = async_to_streamed_response_wrapper(
secrets.retrieve,
)
self.update = async_to_streamed_response_wrapper(
secrets.update,
)
Expand Down
Loading