diff --git a/EXAMPLES.md b/EXAMPLES.md
index eb1222c9c..daf4924e4 100644
--- a/EXAMPLES.md
+++ b/EXAMPLES.md
@@ -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)
## Blueprint with Build Context
@@ -135,3 +136,34 @@ uv run pytest -m smoketest tests/smoketests/examples/
```
**Source:** [`examples/mcp_github_tools.py`](./examples/mcp_github_tools.py)
+
+
+## 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)
diff --git a/README-SDK.md b/README-SDK.md
index 115eacf22..7a03ae724 100644
--- a/README-SDK.md
+++ b/README-SDK.md
@@ -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
diff --git a/examples/mcp_github_tools.py b/examples/mcp_github_tools.py
index b2850c7af..465ed1797 100644
--- a/examples/mcp_github_tools.py
+++ b/examples/mcp_github_tools.py
@@ -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(
diff --git a/examples/registry.py b/examples/registry.py
index cde21979d..8bb04cba0 100644
--- a/examples/registry.py
+++ b/examples/registry.py
@@ -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
@@ -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,
+ },
]
diff --git a/examples/secrets_with_devbox.py b/examples/secrets_with_devbox.py
new file mode 100644
index 000000000..c992c53e2
--- /dev/null
+++ b/examples/secrets_with_devbox.py
@@ -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)
diff --git a/llms.txt b/llms.txt
index ed9c684a8..3785def20 100644
--- a/llms.txt
+++ b/llms.txt
@@ -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
@@ -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
diff --git a/scripts/mock b/scripts/mock
index 0b28f6ea2..bcf3b392b 100755
--- a/scripts/mock
+++ b/scripts/mock
@@ -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/prism-cli@5.15.0 -- prism --version
+
npm exec --package=@stainless-api/prism-cli@5.15.0 -- 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
diff --git a/src/runloop_api_client/resources/secrets.py b/src/runloop_api_client/resources/secrets.py
index 892557497..fa7d45471 100644
--- a/src/runloop_api_client/resources/secrets.py
+++ b/src/runloop_api_client/resources/secrets.py
@@ -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,
@@ -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,
@@ -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,
)
@@ -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,
)
@@ -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,
)
@@ -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,
)
diff --git a/src/runloop_api_client/sdk/__init__.py b/src/runloop_api_client/sdk/__init__.py
index e90b86ac1..126b112c8 100644
--- a/src/runloop_api_client/sdk/__init__.py
+++ b/src/runloop_api_client/sdk/__init__.py
@@ -9,6 +9,7 @@
AgentOps,
DevboxOps,
ScorerOps,
+ SecretOps,
RunloopSDK,
ScenarioOps,
SnapshotOps,
@@ -25,6 +26,7 @@
AsyncAgentOps,
AsyncDevboxOps,
AsyncScorerOps,
+ AsyncSecretOps,
AsyncRunloopSDK,
AsyncScenarioOps,
AsyncSnapshotOps,
@@ -37,6 +39,7 @@
)
from .devbox import Devbox, NamedShell
from .scorer import Scorer
+from .secret import Secret
from .scenario import Scenario
from .snapshot import Snapshot
from .benchmark import Benchmark
@@ -46,6 +49,7 @@
from .async_agent import AsyncAgent
from .async_devbox import AsyncDevbox, AsyncNamedShell
from .async_scorer import AsyncScorer
+from .async_secret import AsyncSecret
from .scenario_run import ScenarioRun
from .benchmark_run import BenchmarkRun
from .async_scenario import AsyncScenario
@@ -82,6 +86,8 @@
"AsyncBlueprintOps",
"ScenarioOps",
"AsyncScenarioOps",
+ "SecretOps",
+ "AsyncSecretOps",
"ScorerOps",
"AsyncScorerOps",
"SnapshotOps",
@@ -97,6 +103,7 @@
# Resource classes
"Agent",
"AsyncAgent",
+ "AsyncSecret",
"Benchmark",
"AsyncBenchmark",
"BenchmarkRun",
@@ -116,6 +123,8 @@
"ScenarioBuilder",
"AsyncScenarioBuilder",
"ScenarioPreview",
+ "Secret",
+ "AsyncSecret",
"Scorer",
"AsyncScorer",
"Snapshot",
diff --git a/src/runloop_api_client/sdk/async_.py b/src/runloop_api_client/sdk/async_.py
index 872e80530..6dcb6dff4 100644
--- a/src/runloop_api_client/sdk/async_.py
+++ b/src/runloop_api_client/sdk/async_.py
@@ -11,6 +11,7 @@
import httpx
from ._types import (
+ BaseRequestOptions,
LongRequestOptions,
SDKAgentListParams,
SDKDevboxListParams,
@@ -40,11 +41,13 @@
from .async_agent import AsyncAgent
from .async_devbox import AsyncDevbox
from .async_scorer import AsyncScorer
+from .async_secret import AsyncSecret
from .async_scenario import AsyncScenario
from .async_snapshot import AsyncSnapshot
from .async_benchmark import AsyncBenchmark
from .async_blueprint import AsyncBlueprint
from .async_mcp_config import AsyncMcpConfig
+from ..types.secret_view import SecretView
from ..lib.context_loader import TarFilter, build_directory_tar
from .async_gateway_config import AsyncGatewayConfig
from .async_network_policy import AsyncNetworkPolicy
@@ -1070,6 +1073,127 @@ async def list(self, **params: Unpack[SDKMcpConfigListParams]) -> list[AsyncMcpC
return [AsyncMcpConfig(self._client, item.id) for item in page.mcp_configs]
+class AsyncSecretOps:
+ """High-level async manager for creating and managing Secrets.
+
+ Accessed via ``runloop.secret`` from :class:`AsyncRunloopSDK`, provides methods to
+ create, retrieve, update, list, and delete secrets. Secrets are encrypted
+ key-value pairs that can be injected into Devboxes as environment variables.
+
+ Example:
+ >>> runloop = AsyncRunloopSDK()
+ >>> secret = await runloop.secret.create(name="MY_API_KEY", value="secret-value")
+ >>> secrets = await runloop.secret.list()
+ """
+
+ def __init__(self, client: AsyncRunloop) -> None:
+ """Initialize AsyncSecretOps.
+
+ :param client: AsyncRunloop client instance
+ :type client: AsyncRunloop
+ """
+ self._client = client
+
+ async def create(self, name: str, value: str, **options: Unpack[LongRequestOptions]) -> AsyncSecret:
+ """Create a new secret.
+
+ Example:
+ >>> secret = await runloop.secret.create(
+ ... name="DATABASE_PASSWORD",
+ ... value="my-secure-password",
+ ... )
+ >>> print(f"Created secret: {secret.name}")
+
+ :param name: Globally unique secret name (must be a valid env var name)
+ :type name: str
+ :param value: Secret value to store (encrypted at rest)
+ :type value: str
+ :param options: Optional request configuration
+ :return: The created AsyncSecret instance
+ :rtype: AsyncSecret
+ """
+ view = await self._client.secrets.create(name=name, value=value, **options)
+ return AsyncSecret(self._client, view.name, view.id)
+
+ def from_name(self, name: str) -> AsyncSecret:
+ """Get an AsyncSecret instance by name without making an API call.
+
+ Use ``get_info()`` on the returned AsyncSecret to fetch the actual data.
+
+ Example:
+ >>> secret = runloop.secret.from_name("MY_API_KEY")
+ >>> info = await secret.get_info()
+ >>> print(f"Secret ID: {info.id}")
+
+ :param name: The globally unique name of the secret
+ :type name: str
+ :return: An AsyncSecret instance (no API call made)
+ :rtype: AsyncSecret
+ """
+ return AsyncSecret(self._client, name)
+
+ async def update(
+ self,
+ secret: "AsyncSecret | str",
+ value: str,
+ **options: Unpack[LongRequestOptions],
+ ) -> AsyncSecret:
+ """Update an existing secret's value.
+
+ Example:
+ >>> updated = await runloop.secret.update("DATABASE_PASSWORD", "new-password")
+ >>> # Or using an AsyncSecret object
+ >>> updated = await runloop.secret.update(secret, "new-password")
+
+ :param secret: The secret to update (AsyncSecret object or name string)
+ :type secret: AsyncSecret | str
+ :param value: The new secret value
+ :type value: str
+ :param options: Optional request configuration
+ :return: The updated AsyncSecret instance
+ :rtype: AsyncSecret
+ """
+ name = secret if isinstance(secret, str) else secret.name
+ await self._client.secrets.update(name, value=value, **options)
+ return AsyncSecret(self._client, name)
+
+ async def list(self, **options: Unpack[BaseRequestOptions]) -> list[AsyncSecret]:
+ """List all secrets.
+
+ Example:
+ >>> secrets = await runloop.secret.list()
+ >>> for s in secrets:
+ ... print(s.name)
+
+ :param options: Optional request configuration
+ :return: List of AsyncSecret instances
+ :rtype: list[AsyncSecret]
+ """
+ result = await self._client.secrets.list(**options)
+ return [AsyncSecret(self._client, view.name, view.id) for view in result.secrets]
+
+ async def delete(
+ self,
+ secret: "AsyncSecret | str",
+ **options: Unpack[LongRequestOptions],
+ ) -> SecretView:
+ """Delete a secret. This action is irreversible.
+
+ Example:
+ >>> await runloop.secret.delete("DATABASE_PASSWORD")
+ >>> # Or using an AsyncSecret object
+ >>> await runloop.secret.delete(secret)
+
+ :param secret: The secret to delete (AsyncSecret object or name string)
+ :type secret: AsyncSecret | str
+ :param options: Optional request configuration
+ :return: The deleted secret metadata
+ :rtype: SecretView
+ """
+ name = secret if isinstance(secret, str) else secret.name
+ return await self._client.secrets.delete(name, **options)
+
+
class AsyncRunloopSDK:
"""High-level asynchronous entry point for the Runloop SDK.
@@ -1101,6 +1225,8 @@ class AsyncRunloopSDK:
:vartype gateway_config: AsyncGatewayConfigOps
:ivar mcp_config: High-level async interface for MCP config management
:vartype mcp_config: AsyncMcpConfigOps
+ :ivar secret: High-level async interface for secret management
+ :vartype secret: AsyncSecretOps
Example:
>>> runloop = AsyncRunloopSDK() # Uses RUNLOOP_API_KEY env var
@@ -1120,6 +1246,7 @@ class AsyncRunloopSDK:
network_policy: AsyncNetworkPolicyOps
scenario: AsyncScenarioOps
scorer: AsyncScorerOps
+ secret: AsyncSecretOps
snapshot: AsyncSnapshotOps
storage_object: AsyncStorageObjectOps
@@ -1168,6 +1295,7 @@ def __init__(
self.gateway_config = AsyncGatewayConfigOps(self.api)
self.mcp_config = AsyncMcpConfigOps(self.api)
self.network_policy = AsyncNetworkPolicyOps(self.api)
+ self.secret = AsyncSecretOps(self.api)
self.scenario = AsyncScenarioOps(self.api)
self.scorer = AsyncScorerOps(self.api)
self.snapshot = AsyncSnapshotOps(self.api)
diff --git a/src/runloop_api_client/sdk/async_secret.py b/src/runloop_api_client/sdk/async_secret.py
new file mode 100644
index 000000000..189212c42
--- /dev/null
+++ b/src/runloop_api_client/sdk/async_secret.py
@@ -0,0 +1,130 @@
+"""Secret resource class for asynchronous operations."""
+
+from __future__ import annotations
+
+from typing_extensions import Unpack, override
+
+from ._types import BaseRequestOptions, LongRequestOptions
+from .._client import AsyncRunloop
+from ..types.secret_view import SecretView
+
+
+class AsyncSecret:
+ """Asynchronous wrapper around a secret resource.
+
+ Secrets are encrypted key-value pairs that can be securely stored and injected
+ into Devboxes as environment variables. Secrets are identified by their globally
+ unique name.
+
+ Example:
+ >>> runloop = AsyncRunloopSDK()
+ >>> secret = await runloop.secret.create(
+ ... name="MY_API_KEY",
+ ... value="secret-value",
+ ... )
+ >>> info = await secret.get_info()
+ >>> print(f"Secret: {info.name}, ID: {info.id}")
+ """
+
+ def __init__(
+ self,
+ client: AsyncRunloop,
+ name: str,
+ id: str | None = None,
+ ) -> None:
+ """Initialize the wrapper.
+
+ :param client: Generated AsyncRunloop client
+ :type client: AsyncRunloop
+ :param name: The globally unique name of the secret
+ :type name: str
+ :param id: The secret ID (optional, may not be known until get_info is called)
+ :type id: str | None
+ """
+ self._client = client
+ self._name = name
+ self._id = id
+
+ @override
+ def __repr__(self) -> str:
+ return f""
+
+ @property
+ def id(self) -> str | None:
+ """Return the secret ID.
+
+ :return: Secret ID, or None if not yet fetched from API
+ :rtype: str | None
+ """
+ return self._id
+
+ @property
+ def name(self) -> str:
+ """Return the secret name.
+
+ :return: Globally unique secret name
+ :rtype: str
+ """
+ return self._name
+
+ async def get_info(
+ self,
+ **options: Unpack[BaseRequestOptions],
+ ) -> SecretView:
+ """Retrieve the latest secret details from the API.
+
+ Note: The secret value is never returned for security reasons.
+
+ Example:
+ >>> info = await secret.get_info()
+ >>> print(f"Secret: {info.name}, ID: {info.id}, Created: {info.create_time_ms}")
+
+ :param options: Optional request configuration
+ :return: API response describing the secret (value not included)
+ :rtype: SecretView
+ """
+ return await self._client.secrets.retrieve(
+ self._name,
+ **options,
+ )
+
+ async def update(
+ self,
+ value: str,
+ **options: Unpack[LongRequestOptions],
+ ) -> SecretView:
+ """Update this secret's value.
+
+ Example:
+ >>> updated = await secret.update("new-secret-value")
+ >>> print(f"Updated at: {updated.update_time_ms}")
+
+ :param value: The new secret value (will be encrypted at rest)
+ :type value: str
+ :param options: Optional request configuration
+ :return: Updated secret view
+ :rtype: SecretView
+ """
+ return await self._client.secrets.update(
+ self._name,
+ value=value,
+ **options,
+ )
+
+ async def delete(
+ self,
+ **options: Unpack[LongRequestOptions],
+ ) -> SecretView:
+ """Delete this secret. This action is irreversible.
+
+ Example:
+ >>> await secret.delete()
+
+ :param options: Optional long-running request configuration
+ :return: API response acknowledging deletion
+ :rtype: SecretView
+ """
+ return await self._client.secrets.delete(
+ self._name,
+ **options,
+ )
diff --git a/src/runloop_api_client/sdk/secret.py b/src/runloop_api_client/sdk/secret.py
new file mode 100644
index 000000000..8fdfa8c0d
--- /dev/null
+++ b/src/runloop_api_client/sdk/secret.py
@@ -0,0 +1,130 @@
+"""Secret resource class for synchronous operations."""
+
+from __future__ import annotations
+
+from typing_extensions import Unpack, override
+
+from ._types import BaseRequestOptions, LongRequestOptions
+from .._client import Runloop
+from ..types.secret_view import SecretView
+
+
+class Secret:
+ """Synchronous wrapper around a secret resource.
+
+ Secrets are encrypted key-value pairs that can be securely stored and injected
+ into Devboxes as environment variables. Secrets are identified by their globally
+ unique name.
+
+ Example:
+ >>> runloop = RunloopSDK()
+ >>> secret = runloop.secret.create(
+ ... name="MY_API_KEY",
+ ... value="secret-value",
+ ... )
+ >>> info = secret.get_info()
+ >>> print(f"Secret: {info.name}, ID: {info.id}")
+ """
+
+ def __init__(
+ self,
+ client: Runloop,
+ name: str,
+ id: str | None = None,
+ ) -> None:
+ """Initialize the wrapper.
+
+ :param client: Generated Runloop client
+ :type client: Runloop
+ :param name: The globally unique name of the secret
+ :type name: str
+ :param id: The secret ID (optional, may not be known until getInfo is called)
+ :type id: str | None
+ """
+ self._client = client
+ self._name = name
+ self._id = id
+
+ @override
+ def __repr__(self) -> str:
+ return f""
+
+ @property
+ def id(self) -> str | None:
+ """Return the secret ID.
+
+ :return: Secret ID, or None if not yet fetched from API
+ :rtype: str | None
+ """
+ return self._id
+
+ @property
+ def name(self) -> str:
+ """Return the secret name.
+
+ :return: Globally unique secret name
+ :rtype: str
+ """
+ return self._name
+
+ def get_info(
+ self,
+ **options: Unpack[BaseRequestOptions],
+ ) -> SecretView:
+ """Retrieve the latest secret details from the API.
+
+ Note: The secret value is never returned for security reasons.
+
+ Example:
+ >>> info = secret.get_info()
+ >>> print(f"Secret: {info.name}, ID: {info.id}, Created: {info.create_time_ms}")
+
+ :param options: Optional request configuration
+ :return: API response describing the secret (value not included)
+ :rtype: SecretView
+ """
+ return self._client.secrets.retrieve(
+ self._name,
+ **options,
+ )
+
+ def update(
+ self,
+ value: str,
+ **options: Unpack[LongRequestOptions],
+ ) -> SecretView:
+ """Update this secret's value.
+
+ Example:
+ >>> updated = secret.update("new-secret-value")
+ >>> print(f"Updated at: {updated.update_time_ms}")
+
+ :param value: The new secret value (will be encrypted at rest)
+ :type value: str
+ :param options: Optional request configuration
+ :return: Updated secret view
+ :rtype: SecretView
+ """
+ return self._client.secrets.update(
+ self._name,
+ value=value,
+ **options,
+ )
+
+ def delete(
+ self,
+ **options: Unpack[LongRequestOptions],
+ ) -> SecretView:
+ """Delete this secret. This action is irreversible.
+
+ Example:
+ >>> secret.delete()
+
+ :param options: Optional long-running request configuration
+ :return: API response acknowledging deletion
+ :rtype: SecretView
+ """
+ return self._client.secrets.delete(
+ self._name,
+ **options,
+ )
diff --git a/src/runloop_api_client/sdk/sync.py b/src/runloop_api_client/sdk/sync.py
index a43fa198b..07dd5e8fe 100644
--- a/src/runloop_api_client/sdk/sync.py
+++ b/src/runloop_api_client/sdk/sync.py
@@ -11,6 +11,7 @@
from .agent import Agent
from ._types import (
+ BaseRequestOptions,
LongRequestOptions,
SDKAgentListParams,
SDKDevboxListParams,
@@ -36,6 +37,7 @@
)
from .devbox import Devbox
from .scorer import Scorer
+from .secret import Secret
from .._types import Timeout, NotGiven, not_given
from .._client import DEFAULT_MAX_RETRIES, Runloop
from ._helpers import detect_content_type
@@ -48,6 +50,7 @@
from .network_policy import NetworkPolicy
from .storage_object import StorageObject
from .scenario_builder import ScenarioBuilder
+from ..types.secret_view import SecretView
from ..lib.context_loader import TarFilter, build_directory_tar
from ..types.object_create_params import ContentType
from ..types.shared_params.agent_source import Git, Npm, Pip, Object
@@ -1095,6 +1098,127 @@ def list(self, **params: Unpack[SDKMcpConfigListParams]) -> list[McpConfig]:
return [McpConfig(self._client, item.id) for item in page.mcp_configs]
+class SecretOps:
+ """High-level manager for creating and managing Secrets.
+
+ Accessed via ``runloop.secret`` from :class:`RunloopSDK`, provides methods to
+ create, retrieve, update, list, and delete secrets. Secrets are encrypted
+ key-value pairs that can be injected into Devboxes as environment variables.
+
+ Example:
+ >>> runloop = RunloopSDK()
+ >>> secret = runloop.secret.create(name="MY_API_KEY", value="secret-value")
+ >>> secrets = runloop.secret.list()
+ """
+
+ def __init__(self, client: Runloop) -> None:
+ """Initialize SecretOps.
+
+ :param client: Runloop client instance
+ :type client: Runloop
+ """
+ self._client = client
+
+ def create(self, name: str, value: str, **options: Unpack[LongRequestOptions]) -> Secret:
+ """Create a new secret.
+
+ Example:
+ >>> secret = runloop.secret.create(
+ ... name="DATABASE_PASSWORD",
+ ... value="my-secure-password",
+ ... )
+ >>> print(f"Created secret: {secret.name}")
+
+ :param name: Globally unique secret name (must be a valid env var name)
+ :type name: str
+ :param value: Secret value to store (encrypted at rest)
+ :type value: str
+ :param options: Optional request configuration
+ :return: The created Secret instance
+ :rtype: Secret
+ """
+ view = self._client.secrets.create(name=name, value=value, **options)
+ return Secret(self._client, view.name, view.id)
+
+ def from_name(self, name: str) -> Secret:
+ """Get a Secret instance by name without making an API call.
+
+ Use ``get_info()`` on the returned Secret to fetch the actual data.
+
+ Example:
+ >>> secret = runloop.secret.from_name("MY_API_KEY")
+ >>> info = secret.get_info()
+ >>> print(f"Secret ID: {info.id}")
+
+ :param name: The globally unique name of the secret
+ :type name: str
+ :return: A Secret instance (no API call made)
+ :rtype: Secret
+ """
+ return Secret(self._client, name)
+
+ def update(
+ self,
+ secret: "Secret | str",
+ value: str,
+ **options: Unpack[LongRequestOptions],
+ ) -> Secret:
+ """Update an existing secret's value.
+
+ Example:
+ >>> updated = runloop.secret.update("DATABASE_PASSWORD", "new-password")
+ >>> # Or using a Secret object
+ >>> updated = runloop.secret.update(secret, "new-password")
+
+ :param secret: The secret to update (Secret object or name string)
+ :type secret: Secret | str
+ :param value: The new secret value
+ :type value: str
+ :param options: Optional request configuration
+ :return: The updated Secret instance
+ :rtype: Secret
+ """
+ name = secret if isinstance(secret, str) else secret.name
+ self._client.secrets.update(name, value=value, **options)
+ return Secret(self._client, name)
+
+ def list(self, **options: Unpack[BaseRequestOptions]) -> list[Secret]:
+ """List all secrets.
+
+ Example:
+ >>> secrets = runloop.secret.list()
+ >>> for s in secrets:
+ ... print(s.name)
+
+ :param options: Optional request configuration
+ :return: List of Secret instances
+ :rtype: list[Secret]
+ """
+ result = self._client.secrets.list(**options)
+ return [Secret(self._client, view.name, view.id) for view in result.secrets]
+
+ def delete(
+ self,
+ secret: "Secret | str",
+ **options: Unpack[LongRequestOptions],
+ ) -> SecretView:
+ """Delete a secret. This action is irreversible.
+
+ Example:
+ >>> runloop.secret.delete("DATABASE_PASSWORD")
+ >>> # Or using a Secret object
+ >>> runloop.secret.delete(secret)
+
+ :param secret: The secret to delete (Secret object or name string)
+ :type secret: Secret | str
+ :param options: Optional request configuration
+ :return: The deleted secret metadata
+ :rtype: SecretView
+ """
+ name = secret if isinstance(secret, str) else secret.name
+ return self._client.secrets.delete(name, **options)
+
+
class RunloopSDK:
"""High-level synchronous entry point for the Runloop SDK.
@@ -1126,6 +1250,8 @@ class RunloopSDK:
:vartype gateway_config: GatewayConfigOps
:ivar mcp_config: High-level interface for MCP config management
:vartype mcp_config: McpConfigOps
+ :ivar secret: High-level interface for secret management
+ :vartype secret: SecretOps
Example:
>>> runloop = RunloopSDK() # Uses RUNLOOP_API_KEY env var
@@ -1145,6 +1271,7 @@ class RunloopSDK:
network_policy: NetworkPolicyOps
scenario: ScenarioOps
scorer: ScorerOps
+ secret: SecretOps
snapshot: SnapshotOps
storage_object: StorageObjectOps
@@ -1193,6 +1320,7 @@ def __init__(
self.gateway_config = GatewayConfigOps(self.api)
self.mcp_config = McpConfigOps(self.api)
self.network_policy = NetworkPolicyOps(self.api)
+ self.secret = SecretOps(self.api)
self.scenario = ScenarioOps(self.api)
self.scorer = ScorerOps(self.api)
self.snapshot = SnapshotOps(self.api)
diff --git a/tests/smoketests/sdk/test_async_secret.py b/tests/smoketests/sdk/test_async_secret.py
new file mode 100644
index 000000000..a8d3bfb86
--- /dev/null
+++ b/tests/smoketests/sdk/test_async_secret.py
@@ -0,0 +1,128 @@
+"""Asynchronous SDK smoke tests for Secret operations."""
+
+from __future__ import annotations
+
+import pytest
+
+from runloop_api_client.sdk import AsyncRunloopSDK
+from tests.smoketests.utils import unique_name
+
+pytestmark = [pytest.mark.smoketest, pytest.mark.asyncio]
+
+THIRTY_SECOND_TIMEOUT = 30
+TWO_MINUTE_TIMEOUT = 120
+
+
+class TestAsyncSecretLifecycle:
+ """Test async secret lifecycle operations: create, get_info, update, list, delete."""
+
+ @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT)
+ async def test_secret_full_lifecycle(self, async_sdk_client: AsyncRunloopSDK) -> None:
+ """Test complete async secret lifecycle: create, get_info, update (both ways), list, from_name, delete."""
+ secret_name = unique_name("SDK_ASYNC_TEST_SECRET").upper().replace("-", "_")
+
+ # Create
+ secret = await async_sdk_client.secret.create(name=secret_name, value="initial-value")
+ assert secret is not None
+ assert secret.name == secret_name
+
+ try:
+ # get_info uses GET /v1/secrets/{name}
+ info = await secret.get_info()
+ assert info.id.startswith("sec_")
+ assert info.name == secret_name
+ assert info.create_time_ms > 0
+ create_time_ms = info.create_time_ms
+
+ # Update via AsyncSecretOps
+ updated = await async_sdk_client.secret.update(secret, "updated-via-ops")
+ assert updated.name == secret_name
+ updated_info = await updated.get_info()
+ assert updated_info.update_time_ms >= create_time_ms
+
+ # Update via instance method
+ instance_updated_info = await secret.update("updated-via-instance")
+ assert instance_updated_info.name == secret_name
+
+ # List
+ secrets = await async_sdk_client.secret.list()
+ assert isinstance(secrets, list)
+ assert len(secrets) > 0
+ found = next((s for s in secrets if s.name == secret_name), None)
+ assert found is not None
+ assert found.name == secret_name
+
+ # from_name (no API call)
+ by_name = async_sdk_client.secret.from_name(secret_name)
+ assert by_name.name == secret_name
+ by_name_info = await by_name.get_info()
+ assert by_name_info.id == info.id
+
+ finally:
+ # Delete
+ deleted = await secret.delete()
+ assert deleted is not None
+ assert deleted.name == secret_name
+
+ # Verify deleted
+ remaining = await async_sdk_client.secret.list()
+ assert all(s.name != secret_name for s in remaining)
+
+ @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT)
+ async def test_secret_delete_via_ops(self, async_sdk_client: AsyncRunloopSDK) -> None:
+ """Test deleting a secret via AsyncSecretOps.delete()."""
+ secret_name = unique_name("SDK_ASYNC_DEL_SECRET").upper().replace("-", "_")
+ secret = await async_sdk_client.secret.create(name=secret_name, value="to-be-deleted")
+
+ try:
+ deleted = await async_sdk_client.secret.delete(secret)
+ assert deleted.name == secret_name
+
+ remaining = await async_sdk_client.secret.list()
+ assert all(s.name != secret_name for s in remaining)
+ except Exception:
+ try:
+ await secret.delete()
+ except Exception:
+ pass
+ raise
+
+
+class TestAsyncSecretWithDevbox:
+ """Test async secret injection into devboxes."""
+
+ @pytest.mark.timeout(TWO_MINUTE_TIMEOUT)
+ async def test_devbox_can_access_injected_secret(self, async_sdk_client: AsyncRunloopSDK) -> None:
+ """Test that a secret injected into a devbox is accessible as an env var."""
+ secret_name = unique_name("SDK_ASYNC_DEVBOX_SECRET").upper().replace("-", "_")
+ secret_value = "async-secret-for-devbox-test"
+
+ secret = await async_sdk_client.secret.create(name=secret_name, value=secret_value)
+
+ devbox = None
+ try:
+ devbox = await async_sdk_client.devbox.create(
+ name=unique_name("async-secret-test-devbox"),
+ secrets={
+ "MY_SECRET_VAR": secret.name,
+ },
+ launch_parameters={
+ "resource_size_request": "X_SMALL",
+ "keep_alive_time_seconds": 60,
+ },
+ )
+
+ result = await devbox.cmd.exec("echo $MY_SECRET_VAR")
+ assert result.exit_code == 0
+ assert (await result.stdout()).strip() == secret_value
+
+ finally:
+ if devbox is not None:
+ try:
+ await devbox.shutdown()
+ except Exception:
+ pass
+ try:
+ await secret.delete()
+ except Exception:
+ pass
diff --git a/tests/smoketests/sdk/test_secret.py b/tests/smoketests/sdk/test_secret.py
new file mode 100644
index 000000000..33f460090
--- /dev/null
+++ b/tests/smoketests/sdk/test_secret.py
@@ -0,0 +1,145 @@
+"""Synchronous SDK smoke tests for Secret operations."""
+
+from __future__ import annotations
+
+import pytest
+
+from runloop_api_client.sdk import RunloopSDK
+from tests.smoketests.utils import unique_name
+
+pytestmark = [pytest.mark.smoketest]
+
+THIRTY_SECOND_TIMEOUT = 30
+TWO_MINUTE_TIMEOUT = 120
+
+
+class TestSecretLifecycle:
+ """Test secret lifecycle operations: create, get_info, update, list, delete."""
+
+ @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT)
+ def test_secret_full_lifecycle(self, sdk_client: RunloopSDK) -> None:
+ """Test complete secret lifecycle: create, get_info, update (both ways), list, from_name, delete."""
+ secret_name = unique_name("SDK_TEST_SECRET").upper().replace("-", "_")
+
+ # Create
+ secret = sdk_client.secret.create(name=secret_name, value="initial-value")
+ assert secret is not None
+ assert secret.name == secret_name
+
+ try:
+ # get_info uses GET /v1/secrets/{name}
+ info = secret.get_info()
+ assert info.id.startswith("sec_")
+ assert info.name == secret_name
+ assert info.create_time_ms > 0
+ create_time_ms = info.create_time_ms
+
+ # Update via SecretOps
+ updated = sdk_client.secret.update(secret, "updated-via-ops")
+ assert updated.name == secret_name
+ updated_info = updated.get_info()
+ assert updated_info.update_time_ms >= create_time_ms
+
+ # Update via instance method
+ instance_updated_info = secret.update("updated-via-instance")
+ assert instance_updated_info.name == secret_name
+
+ # List
+ secrets = sdk_client.secret.list()
+ assert isinstance(secrets, list)
+ assert len(secrets) > 0
+ found = next((s for s in secrets if s.name == secret_name), None)
+ assert found is not None
+ assert found.name == secret_name
+
+ # from_name (no API call)
+ by_name = sdk_client.secret.from_name(secret_name)
+ assert by_name.name == secret_name
+ by_name_info = by_name.get_info()
+ assert by_name_info.id == info.id
+
+ finally:
+ # Delete
+ deleted = secret.delete()
+ assert deleted is not None
+ assert deleted.name == secret_name
+
+ # Verify deleted
+ remaining = sdk_client.secret.list()
+ assert all(s.name != secret_name for s in remaining)
+
+ @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT)
+ def test_secret_delete_via_ops(self, sdk_client: RunloopSDK) -> None:
+ """Test deleting a secret via SecretOps.delete()."""
+ secret_name = unique_name("SDK_TEST_DEL_SECRET").upper().replace("-", "_")
+ secret = sdk_client.secret.create(name=secret_name, value="to-be-deleted")
+
+ try:
+ deleted = sdk_client.secret.delete(secret)
+ assert deleted.name == secret_name
+
+ remaining = sdk_client.secret.list()
+ assert all(s.name != secret_name for s in remaining)
+ except Exception:
+ # Cleanup if delete failed
+ try:
+ secret.delete()
+ except Exception:
+ pass
+ raise
+
+ @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT)
+ def test_secret_delete_by_name_string(self, sdk_client: RunloopSDK) -> None:
+ """Test deleting a secret by passing a name string to SecretOps.delete()."""
+ secret_name = unique_name("SDK_TEST_STR_SECRET").upper().replace("-", "_")
+ sdk_client.secret.create(name=secret_name, value="to-be-deleted-by-name")
+
+ try:
+ deleted = sdk_client.secret.delete(secret_name)
+ assert deleted.name == secret_name
+ except Exception:
+ try:
+ sdk_client.secret.delete(secret_name)
+ except Exception:
+ pass
+ raise
+
+
+class TestSecretWithDevbox:
+ """Test secret injection into devboxes."""
+
+ @pytest.mark.timeout(TWO_MINUTE_TIMEOUT)
+ def test_devbox_can_access_injected_secret(self, sdk_client: RunloopSDK) -> None:
+ """Test that a secret injected into a devbox is accessible as an env var."""
+ secret_name = unique_name("SDK_DEVBOX_SECRET").upper().replace("-", "_")
+ secret_value = "secret-for-devbox-test"
+
+ secret = sdk_client.secret.create(name=secret_name, value=secret_value)
+
+ devbox = None
+ try:
+ devbox = sdk_client.devbox.create(
+ name=unique_name("secret-test-devbox"),
+ secrets={
+ "MY_SECRET_VAR": secret.name,
+ },
+ launch_parameters={
+ "resource_size_request": "X_SMALL",
+ "keep_alive_time_seconds": 60,
+ },
+ )
+
+ result = devbox.cmd.exec("echo $MY_SECRET_VAR")
+ assert result.exit_code == 0
+ assert result.stdout().strip() == secret_value
+
+ finally:
+ if devbox is not None:
+ try:
+ devbox.shutdown()
+ except Exception:
+ pass
+ try:
+ secret.delete()
+ except Exception:
+ pass