Skip to content

Commit 82020d7

Browse files
feat: add secrets to oo-sdk (#756)
Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com>
1 parent dfedad9 commit 82020d7

File tree

14 files changed

+1033
-3
lines changed

14 files changed

+1033
-3
lines changed

EXAMPLES.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Runnable examples live in [`examples/`](./examples).
1111
- [Devbox From Blueprint (Run Command, Shutdown)](#devbox-from-blueprint-lifecycle)
1212
- [Devbox Snapshot and Resume](#devbox-snapshot-resume)
1313
- [MCP Hub + Claude Code + GitHub](#mcp-github-tools)
14+
- [Secrets with Devbox (Create, Inject, Verify, Delete)](#secrets-with-devbox)
1415

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

137138
**Source:** [`examples/mcp_github_tools.py`](./examples/mcp_github_tools.py)
139+
140+
<a id="secrets-with-devbox"></a>
141+
## Secrets with Devbox (Create, Inject, Verify, Delete)
142+
143+
**Use case:** Create a secret, inject it into a devbox as an environment variable, verify access, and clean up.
144+
145+
**Tags:** `secrets`, `devbox`, `environment-variables`, `cleanup`
146+
147+
### Workflow
148+
- Create a secret with a test value
149+
- Create a devbox with the secret mapped to an env var
150+
- Execute a command that reads the secret from the environment
151+
- Verify the value matches
152+
- Update the secret and verify
153+
- List secrets and verify the secret appears
154+
- Shutdown devbox and delete secret
155+
156+
### Prerequisites
157+
- `RUNLOOP_API_KEY`
158+
159+
### Run
160+
```sh
161+
uv run python -m examples.secrets_with_devbox
162+
```
163+
164+
### Test
165+
```sh
166+
uv run pytest -m smoketest tests/smoketests/examples/
167+
```
168+
169+
**Source:** [`examples/secrets_with_devbox.py`](./examples/secrets_with_devbox.py)

README-SDK.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ The SDK provides object-oriented interfaces for all major Runloop resources:
116116
- **`runloop.blueprint`** - Blueprint management (create, list, build blueprints)
117117
- **`runloop.snapshot`** - Snapshot management (list disk snapshots)
118118
- **`runloop.storage_object`** - Storage object management (upload, download, list objects)
119+
- **`runloop.secret`** - Secret management (create, update, list, delete encrypted key-value pairs)
119120
- **`runloop.api`** - Direct access to the underlying REST API client
120121

121122
### Devbox

examples/mcp_github_tools.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,9 @@ def recipe(ctx: RecipeContext, options: McpExampleOptions) -> RecipeOutput: # n
8080

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

8787
# Launch a devbox with MCP Hub wiring
8888
devbox = sdk.devbox.create(

examples/registry.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from .example_types import ExampleResult
1111
from .mcp_github_tools import run_mcp_github_tools_example
12+
from .secrets_with_devbox import run_secrets_with_devbox_example
1213
from .devbox_snapshot_resume import run_devbox_snapshot_resume_example
1314
from .blueprint_with_build_context import run_blueprint_with_build_context_example
1415
from .devbox_from_blueprint_lifecycle import run_devbox_from_blueprint_lifecycle_example
@@ -44,6 +45,13 @@
4445
"required_env": ["RUNLOOP_API_KEY", "GITHUB_TOKEN", "ANTHROPIC_API_KEY"],
4546
"run": run_mcp_github_tools_example,
4647
},
48+
{
49+
"slug": "secrets-with-devbox",
50+
"title": "Secrets with Devbox (Create, Inject, Verify, Delete)",
51+
"file_name": "secrets_with_devbox.py",
52+
"required_env": ["RUNLOOP_API_KEY"],
53+
"run": run_secrets_with_devbox_example,
54+
},
4755
]
4856

4957

examples/secrets_with_devbox.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
#!/usr/bin/env -S uv run python
2+
"""
3+
---
4+
title: Secrets with Devbox (Create, Inject, Verify, Delete)
5+
slug: secrets-with-devbox
6+
use_case: Create a secret, inject it into a devbox as an environment variable, verify access, and clean up.
7+
workflow:
8+
- Create a secret with a test value
9+
- Create a devbox with the secret mapped to an env var
10+
- Execute a command that reads the secret from the environment
11+
- Verify the value matches
12+
- Update the secret and verify
13+
- List secrets and verify the secret appears
14+
- Shutdown devbox and delete secret
15+
tags:
16+
- secrets
17+
- devbox
18+
- environment-variables
19+
- cleanup
20+
prerequisites:
21+
- RUNLOOP_API_KEY
22+
run: uv run python -m examples.secrets_with_devbox
23+
test: uv run pytest -m smoketest tests/smoketests/examples/
24+
---
25+
"""
26+
27+
from __future__ import annotations
28+
29+
from runloop_api_client import RunloopSDK
30+
31+
from ._harness import run_as_cli, unique_name, wrap_recipe
32+
from .example_types import ExampleCheck, RecipeOutput, RecipeContext
33+
34+
# Note: do NOT hardcode secret values in your code!
35+
# This is example code only; use environment variables instead!
36+
_EXAMPLE_SECRET_VALUE = "my-secret-value"
37+
_UPDATED_SECRET_VALUE = "updated-secret-value"
38+
39+
40+
def recipe(ctx: RecipeContext) -> RecipeOutput:
41+
"""Create a secret, inject it into a devbox, and verify it is accessible."""
42+
cleanup = ctx.cleanup
43+
44+
sdk = RunloopSDK()
45+
resources_created: list[str] = []
46+
checks: list[ExampleCheck] = []
47+
48+
secret_name = unique_name("RUNLOOP_SDK_EXAMPLE").upper().replace("-", "_")
49+
50+
secret = sdk.secret.create(name=secret_name, value=_EXAMPLE_SECRET_VALUE)
51+
resources_created.append(f"secret:{secret_name}")
52+
cleanup.add(f"secret:{secret_name}", lambda: secret.delete())
53+
54+
secret_info = secret.get_info()
55+
checks.append(
56+
ExampleCheck(
57+
name="secret created successfully",
58+
passed=secret.name == secret_name and secret_info.id.startswith("sec_"),
59+
details=f"name={secret.name}, id={secret_info.id}",
60+
)
61+
)
62+
63+
devbox = sdk.devbox.create(
64+
name=unique_name("secrets-example-devbox"),
65+
secrets={
66+
"MY_SECRET_ENV": secret.name,
67+
},
68+
launch_parameters={
69+
"resource_size_request": "X_SMALL",
70+
"keep_alive_time_seconds": 60 * 5,
71+
},
72+
)
73+
resources_created.append(f"devbox:{devbox.id}")
74+
cleanup.add(f"devbox:{devbox.id}", devbox.shutdown)
75+
76+
result = devbox.cmd.exec("echo $MY_SECRET_ENV")
77+
stdout = result.stdout().strip()
78+
checks.append(
79+
ExampleCheck(
80+
name="devbox can read secret as env var",
81+
passed=result.exit_code == 0 and stdout == _EXAMPLE_SECRET_VALUE,
82+
details=f'exit_code={result.exit_code}, stdout="{stdout}"',
83+
)
84+
)
85+
86+
updated_info = sdk.secret.update(secret, _UPDATED_SECRET_VALUE).get_info()
87+
checks.append(
88+
ExampleCheck(
89+
name="secret updated successfully",
90+
passed=updated_info.name == secret_name,
91+
details=f"update_time_ms={updated_info.update_time_ms}",
92+
)
93+
)
94+
95+
secrets = sdk.secret.list()
96+
found = next((s for s in secrets if s.name == secret_name), None)
97+
checks.append(
98+
ExampleCheck(
99+
name="secret appears in list",
100+
passed=found is not None,
101+
details=f"found name={found.name}" if found else "not found",
102+
)
103+
)
104+
105+
return RecipeOutput(resources_created=resources_created, checks=checks)
106+
107+
108+
run_secrets_with_devbox_example = wrap_recipe(recipe)
109+
110+
111+
if __name__ == "__main__":
112+
run_as_cli(run_secrets_with_devbox_example)

llms.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
- [Devbox lifecycle example](examples/devbox_from_blueprint_lifecycle.py): Create blueprint, launch devbox, run commands, cleanup
1414
- [Devbox snapshot and resume example](examples/devbox_snapshot_resume.py): Snapshot disk, resume from snapshot, verify state isolation
1515
- [MCP GitHub example](examples/mcp_github_tools.py): MCP Hub integration with Claude Code
16+
- [Secrets with Devbox example](examples/secrets_with_devbox.py): Create secret, inject into devbox, verify, cleanup
1617

1718
## API Reference
1819

@@ -23,7 +24,7 @@
2324

2425
- **Prefer `AsyncRunloopSDK` over `RunloopSDK`** for better concurrency and performance; all SDK methods have async equivalents
2526
- Use `async with await runloop.devbox.create()` for automatic cleanup via context manager
26-
- For resources without SDK coverage (e.g., secrets, benchmarks), use `runloop.api.*` as a fallback
27+
- For resources without SDK coverage (e.g., benchmarks), use `runloop.api.*` as a fallback
2728
- 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
2829
- 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
2930
- Both `exec` and `exec_async` support streaming callbacks (`stdout`, `stderr`, `output`) for real-time output

src/runloop_api_client/resources/secrets.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,39 @@ def create(
9696
cast_to=SecretView,
9797
)
9898

99+
def retrieve(
100+
self,
101+
name: str,
102+
*,
103+
extra_headers: Headers | None = None,
104+
extra_query: Query | None = None,
105+
extra_body: Body | None = None,
106+
timeout: float | httpx.Timeout | None | NotGiven = not_given,
107+
) -> SecretView:
108+
"""Retrieve a Secret by name.
109+
110+
Args:
111+
extra_headers: Send extra headers
112+
113+
extra_query: Add additional query parameters to the request
114+
115+
extra_body: Add additional JSON properties to the request
116+
117+
timeout: Override the client-level default timeout for this request, in seconds
118+
"""
119+
if not name:
120+
raise ValueError(f"Expected a non-empty value for `name` but received {name!r}")
121+
return self._get(
122+
f"/v1/secrets/{name}",
123+
options=make_request_options(
124+
extra_headers=extra_headers,
125+
extra_query=extra_query,
126+
extra_body=extra_body,
127+
timeout=timeout,
128+
),
129+
cast_to=SecretView,
130+
)
131+
99132
def update(
100133
self,
101134
name: str,
@@ -299,6 +332,39 @@ async def create(
299332
cast_to=SecretView,
300333
)
301334

335+
async def retrieve(
336+
self,
337+
name: str,
338+
*,
339+
extra_headers: Headers | None = None,
340+
extra_query: Query | None = None,
341+
extra_body: Body | None = None,
342+
timeout: float | httpx.Timeout | None | NotGiven = not_given,
343+
) -> SecretView:
344+
"""Retrieve a Secret by name.
345+
346+
Args:
347+
extra_headers: Send extra headers
348+
349+
extra_query: Add additional query parameters to the request
350+
351+
extra_body: Add additional JSON properties to the request
352+
353+
timeout: Override the client-level default timeout for this request, in seconds
354+
"""
355+
if not name:
356+
raise ValueError(f"Expected a non-empty value for `name` but received {name!r}")
357+
return await self._get(
358+
f"/v1/secrets/{name}",
359+
options=make_request_options(
360+
extra_headers=extra_headers,
361+
extra_query=extra_query,
362+
extra_body=extra_body,
363+
timeout=timeout,
364+
),
365+
cast_to=SecretView,
366+
)
367+
302368
async def update(
303369
self,
304370
name: str,
@@ -435,6 +501,9 @@ def __init__(self, secrets: SecretsResource) -> None:
435501
self.create = to_raw_response_wrapper(
436502
secrets.create,
437503
)
504+
self.retrieve = to_raw_response_wrapper(
505+
secrets.retrieve,
506+
)
438507
self.update = to_raw_response_wrapper(
439508
secrets.update,
440509
)
@@ -453,6 +522,9 @@ def __init__(self, secrets: AsyncSecretsResource) -> None:
453522
self.create = async_to_raw_response_wrapper(
454523
secrets.create,
455524
)
525+
self.retrieve = async_to_raw_response_wrapper(
526+
secrets.retrieve,
527+
)
456528
self.update = async_to_raw_response_wrapper(
457529
secrets.update,
458530
)
@@ -471,6 +543,9 @@ def __init__(self, secrets: SecretsResource) -> None:
471543
self.create = to_streamed_response_wrapper(
472544
secrets.create,
473545
)
546+
self.retrieve = to_streamed_response_wrapper(
547+
secrets.retrieve,
548+
)
474549
self.update = to_streamed_response_wrapper(
475550
secrets.update,
476551
)
@@ -489,6 +564,9 @@ def __init__(self, secrets: AsyncSecretsResource) -> None:
489564
self.create = async_to_streamed_response_wrapper(
490565
secrets.create,
491566
)
567+
self.retrieve = async_to_streamed_response_wrapper(
568+
secrets.retrieve,
569+
)
492570
self.update = async_to_streamed_response_wrapper(
493571
secrets.update,
494572
)

src/runloop_api_client/sdk/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
AgentOps,
1010
DevboxOps,
1111
ScorerOps,
12+
SecretOps,
1213
RunloopSDK,
1314
ScenarioOps,
1415
SnapshotOps,
@@ -25,6 +26,7 @@
2526
AsyncAgentOps,
2627
AsyncDevboxOps,
2728
AsyncScorerOps,
29+
AsyncSecretOps,
2830
AsyncRunloopSDK,
2931
AsyncScenarioOps,
3032
AsyncSnapshotOps,
@@ -37,6 +39,7 @@
3739
)
3840
from .devbox import Devbox, NamedShell
3941
from .scorer import Scorer
42+
from .secret import Secret
4043
from .scenario import Scenario
4144
from .snapshot import Snapshot
4245
from .benchmark import Benchmark
@@ -46,6 +49,7 @@
4649
from .async_agent import AsyncAgent
4750
from .async_devbox import AsyncDevbox, AsyncNamedShell
4851
from .async_scorer import AsyncScorer
52+
from .async_secret import AsyncSecret
4953
from .scenario_run import ScenarioRun
5054
from .benchmark_run import BenchmarkRun
5155
from .async_scenario import AsyncScenario
@@ -82,6 +86,8 @@
8286
"AsyncBlueprintOps",
8387
"ScenarioOps",
8488
"AsyncScenarioOps",
89+
"SecretOps",
90+
"AsyncSecretOps",
8591
"ScorerOps",
8692
"AsyncScorerOps",
8793
"SnapshotOps",
@@ -97,6 +103,7 @@
97103
# Resource classes
98104
"Agent",
99105
"AsyncAgent",
106+
"AsyncSecret",
100107
"Benchmark",
101108
"AsyncBenchmark",
102109
"BenchmarkRun",
@@ -116,6 +123,8 @@
116123
"ScenarioBuilder",
117124
"AsyncScenarioBuilder",
118125
"ScenarioPreview",
126+
"Secret",
127+
"AsyncSecret",
119128
"Scorer",
120129
"AsyncScorer",
121130
"Snapshot",

0 commit comments

Comments
 (0)