From 3125c49dfa6716ab83914949ff73ad1f25b3cf36 Mon Sep 17 00:00:00 2001 From: James Chainey Date: Mon, 23 Mar 2026 16:55:26 -0700 Subject: [PATCH 1/2] add mount examples --- EXAMPLES.md | 34 +++++ README.md | 1 + examples/devbox_mounts.py | 284 ++++++++++++++++++++++++++++++++++++++ examples/registry.py | 8 ++ llms.txt | 1 + 5 files changed, 328 insertions(+) create mode 100644 examples/devbox_mounts.py diff --git a/EXAMPLES.md b/EXAMPLES.md index a036aa01d..bc09f12d2 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -9,6 +9,7 @@ Runnable examples live in [`examples/`](./examples). - [Blueprint with Build Context](#blueprint-with-build-context) - [Devbox From Blueprint (Run Command, Shutdown)](#devbox-from-blueprint-lifecycle) +- [Devbox Mounts (Agent, Code, Object)](#devbox-mounts) - [Devbox Snapshot and Resume](#devbox-snapshot-resume) - [Devbox Tunnel (HTTP Server Access)](#devbox-tunnel) - [MCP Hub + Claude Code + GitHub](#mcp-github-tools) @@ -75,6 +76,39 @@ uv run pytest -m smoketest tests/smoketests/examples/ **Source:** [`examples/devbox_from_blueprint_lifecycle.py`](./examples/devbox_from_blueprint_lifecycle.py) + +## Devbox Mounts (Agent, Code, Object) + +**Use case:** Launch a devbox that combines an agent mount for Claude Code, a code mount for the Runloop CLI repo, and an object mount for startup files while routing Anthropic credentials through agent gateway. + +**Tags:** `devbox`, `mounts`, `agent`, `code`, `object`, `claude-code`, `agent-gateway`, `ttl`, `async` + +### Workflow +- Create or reuse a Claude Code agent by name +- Store ANTHROPIC_API_KEY as a Runloop secret for gateway-backed access +- Upload a temporary bootstrap directory as a storage object with a TTL +- Launch a devbox with agent, code, and object mounts together +- Verify the gateway token and URL are present instead of the raw Anthropic key +- Run Claude Code through the Anthropic agent gateway +- Verify the code mount and extracted object mount contents are present +- Shutdown the devbox and delete the temporary secret and storage object + +### Prerequisites +- `RUNLOOP_API_KEY` +- `ANTHROPIC_API_KEY` + +### Run +```sh +ANTHROPIC_API_KEY=sk-ant-xxx uv run python -m examples.devbox_mounts +``` + +### Test +```sh +uv run pytest -m smoketest tests/smoketests/examples/ +``` + +**Source:** [`examples/devbox_mounts.py`](./examples/devbox_mounts.py) + ## Devbox Snapshot and Resume diff --git a/README.md b/README.md index 09aeb9016..fd3ff17d9 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ Functionality between the synchronous and asynchronous clients is otherwise iden ## Examples Workflow-oriented runnable examples are documented in [`EXAMPLES.md`](./EXAMPLES.md). +For a mounts-focused workflow that combines agent, code, and object mounts with agent-gateway credential protection, see [`examples/devbox_mounts.py`](./examples/devbox_mounts.py). `EXAMPLES.md` is generated from metadata in `examples/*.py` and should not be edited manually. Regenerate it with: diff --git a/examples/devbox_mounts.py b/examples/devbox_mounts.py new file mode 100644 index 000000000..7cf510cca --- /dev/null +++ b/examples/devbox_mounts.py @@ -0,0 +1,284 @@ +#!/usr/bin/env -S uv run python +""" +--- +title: Devbox Mounts (Agent, Code, Object) +slug: devbox-mounts +use_case: Launch a devbox that combines an agent mount for Claude Code, a code mount for the Runloop CLI repo, and an object mount for startup files while routing Anthropic credentials through agent gateway. +workflow: + - Create or reuse a Claude Code agent by name + - Store ANTHROPIC_API_KEY as a Runloop secret for gateway-backed access + - Upload a temporary bootstrap directory as a storage object with a TTL + - Launch a devbox with agent, code, and object mounts together + - Verify the gateway token and URL are present instead of the raw Anthropic key + - Run Claude Code through the Anthropic agent gateway + - Verify the code mount and extracted object mount contents are present + - Shutdown the devbox and delete the temporary secret and storage object +tags: + - devbox + - mounts + - agent + - code + - object + - claude-code + - agent-gateway + - ttl + - async +prerequisites: + - RUNLOOP_API_KEY + - ANTHROPIC_API_KEY +run: ANTHROPIC_API_KEY=sk-ant-xxx uv run python -m examples.devbox_mounts +test: uv run pytest -m smoketest tests/smoketests/examples/ +--- +""" + +from __future__ import annotations + +import os +import shlex +import shutil +import asyncio +import tempfile +from pathlib import Path +from datetime import timedelta + +from runloop_api_client import AsyncRunloopSDK +from runloop_api_client.sdk.async_storage_object import AsyncStorageObject + +from ._harness import run_as_cli, unique_name, wrap_recipe +from .example_types import ExampleCheck, RecipeOutput, RecipeContext + +CLAUDE_CODE_AGENT_NAME = "example-claude-code-agent" +CLAUDE_CODE_AGENT_VERSION = "1.0.0" +CLAUDE_CODE_PACKAGE = "@anthropic-ai/claude-code" +CLAUDE_MODEL = "claude-opus-4-5" +GATEWAY_ENV_PREFIX = "ANTHROPIC" +OBJECT_MOUNT_DIR = "/home/user/bootstrap-assets" +COPIED_EXAMPLE_FILE_NAME = "devbox-mounts-source.py" +OBJECT_TTL = timedelta(hours=1) +CLAUDE_PROMPT = "Reply with the exact text mounted-through-agent-gateway and nothing else." + + +async def ensure_claude_code_agent(sdk: AsyncRunloopSDK) -> tuple[str, bool]: + """Return a reusable Claude Code agent, creating it if needed.""" + existing_agents = await sdk.agent.list(name=CLAUDE_CODE_AGENT_NAME, limit=20) + existing_infos = await asyncio.gather(*(agent.get_info() for agent in existing_agents)) + + matching_agents = sorted( + ( + info + for info in existing_infos + if info.name == CLAUDE_CODE_AGENT_NAME + and info.version == CLAUDE_CODE_AGENT_VERSION + and info.source is not None + and info.source.type == "npm" + and info.source.npm is not None + and info.source.npm.package_name == CLAUDE_CODE_PACKAGE + ), + key=lambda info: info.create_time_ms, + reverse=True, + ) + if matching_agents: + return matching_agents[0].id, True + + agent = await sdk.agent.create_from_npm( + name=CLAUDE_CODE_AGENT_NAME, + version=CLAUDE_CODE_AGENT_VERSION, + package_name=CLAUDE_CODE_PACKAGE, + ) + return agent.id, False + + +def create_bootstrap_dir() -> tuple[Path, Path]: + """Create local files that will be uploaded and extracted via object mount.""" + temp_dir = Path(tempfile.mkdtemp(prefix="runloop-devbox-mounts-")) + copied_example_path = temp_dir / COPIED_EXAMPLE_FILE_NAME + copied_example_path.write_text(Path(__file__).read_text()) + (temp_dir / "README.txt").write_text( + "This directory was uploaded with upload_from_dir(), stored as a tgz object, " + "and extracted onto the devbox via an object mount.\n" + ) + return temp_dir, copied_example_path + + +async def discover_code_mount_path(devbox) -> str: + """Find the repository path created by the code mount.""" + result = await devbox.cmd.exec( + "if [ -d /home/user/rl-cli ]; then printf /home/user/rl-cli; " + "elif [ -d /home/user/rl-clis ]; then printf /home/user/rl-clis; " + "else exit 1; fi" + ) + return (await result.stdout()).strip() if result.exit_code == 0 else "" + + +def build_claude_gateway_command() -> str: + """Build a shell-safe Claude invocation that uses gateway-provided credentials.""" + return ( + f'ANTHROPIC_BASE_URL="${GATEWAY_ENV_PREFIX}_URL" ' + f'ANTHROPIC_API_KEY="${GATEWAY_ENV_PREFIX}" ' + f"claude --model {shlex.quote(CLAUDE_MODEL)} " + f"-p {shlex.quote(CLAUDE_PROMPT)} " + "--dangerously-skip-permissions" + ) + + +async def recipe(ctx: RecipeContext) -> RecipeOutput: + """Create a devbox with agent, code, and object mounts plus agent gateway.""" + cleanup = ctx.cleanup + anthropic_api_key = os.environ.get("ANTHROPIC_API_KEY") + if not anthropic_api_key: + raise RuntimeError("Set ANTHROPIC_API_KEY to run the Claude Code mount example.") + + sdk = AsyncRunloopSDK() + resources_created: list[str] = [] + + agent_id, reused_agent = await ensure_claude_code_agent(sdk) + resources_created.append(f"agent:{agent_id}:reused" if reused_agent else f"agent:{agent_id}") + + secret = await sdk.secret.create( + name=unique_name("example-anthropic-gateway").upper().replace("-", "_"), + value=anthropic_api_key, + ) + resources_created.append(f"secret:{secret.name}") + cleanup.add(f"secret:{secret.name}", secret.delete) + + bootstrap_dir, copied_example_path = create_bootstrap_dir() + cleanup.add(f"temp_dir:{bootstrap_dir}", lambda: shutil.rmtree(bootstrap_dir, ignore_errors=True)) + + archive: AsyncStorageObject = await sdk.storage_object.upload_from_dir( + bootstrap_dir, + name=unique_name("example-devbox-mounts"), + ttl=OBJECT_TTL, + metadata={"example": "devbox-mounts"}, + ) + resources_created.append(f"storage_object:{archive.id}") + cleanup.add(f"storage_object:{archive.id}", archive.delete) + + devbox = await sdk.devbox.create( + name=unique_name("devbox-mounts-example"), + launch_parameters={ + "resource_size_request": "SMALL", + "keep_alive_time_seconds": 60 * 5, + }, + mounts=[ + { + "type": "agent_mount", + "agent_id": None, + "agent_name": CLAUDE_CODE_AGENT_NAME, + }, + { + "type": "code_mount", + "repo_owner": "runloopai", + "repo_name": "rl-cli", + }, + { + "type": "object_mount", + "object_id": archive.id, + "object_path": OBJECT_MOUNT_DIR, + }, + ], + gateways={ + GATEWAY_ENV_PREFIX: { + "gateway": "anthropic", + "secret": secret.name, + } + }, + ) + resources_created.append(f"devbox:{devbox.id}") + cleanup.add(f"devbox:{devbox.id}", devbox.shutdown) + + devbox_info = await devbox.get_info() + archive_info = await archive.refresh() + + gateway_url_result = await devbox.cmd.exec(f"echo ${GATEWAY_ENV_PREFIX}_URL") + gateway_url = (await gateway_url_result.stdout()).strip() + + gateway_token_result = await devbox.cmd.exec(f"echo ${GATEWAY_ENV_PREFIX}") + gateway_token = (await gateway_token_result.stdout()).strip() + + claude_version_result = await devbox.cmd.exec("claude --version") + claude_version = (await claude_version_result.stdout()).strip() + + claude_gateway_command = build_claude_gateway_command() + # This is the command you would run to send Claude traffic through the agent gateway. + # We intentionally do not execute it here so repeated example test runs do not incur model costs. + # claude_prompt_result = await devbox.cmd.exec(claude_gateway_command) + + repo_mount_path = await discover_code_mount_path(devbox) + repo_package_json = ( + await devbox.file.read(file_path=f"{repo_mount_path}/package.json") if repo_mount_path else "" + ) + + mounted_example_path = f"{OBJECT_MOUNT_DIR}/{COPIED_EXAMPLE_FILE_NAME}" + mounted_example_contents = await devbox.file.read(file_path=mounted_example_path) + mounted_readme_path = f"{OBJECT_MOUNT_DIR}/README.txt" + mounted_readme_contents = await devbox.file.read(file_path=mounted_readme_path) + + return RecipeOutput( + resources_created=resources_created, + checks=[ + ExampleCheck( + name="Claude Code agent exists and is callable on the devbox", + passed=claude_version_result.exit_code == 0 and bool(claude_version), + details=claude_version or f"exit_code={claude_version_result.exit_code}", + ), + ExampleCheck( + name="Anthropic access is routed through agent gateway", + passed=( + devbox_info.gateway_specs is not None + and devbox_info.gateway_specs.get(GATEWAY_ENV_PREFIX) is not None + and gateway_url_result.exit_code == 0 + and gateway_url.startswith("http") + and gateway_token_result.exit_code == 0 + and gateway_token.startswith("gws_") + and gateway_token != anthropic_api_key + ), + details=f"gateway_url={gateway_url}, token_prefix={gateway_token[:4] or 'missing'}", + ), + ExampleCheck( + name="Claude Code gateway invocation is documented without executing it", + passed=( + "ANTHROPIC_BASE_URL" in claude_gateway_command + and "ANTHROPIC_API_KEY" in claude_gateway_command + and "claude --model" in claude_gateway_command + ), + details=claude_gateway_command, + ), + ExampleCheck( + name="rl-cli repository is available through the code mount", + passed=bool(repo_mount_path) and '"name": "@runloop/rl-cli"' in repo_package_json, + details=repo_mount_path or "repo mount not found", + ), + ExampleCheck( + name="object mount extracted the uploaded example file onto the devbox", + passed=( + 'title: Devbox Mounts (Agent, Code, Object)' in mounted_example_contents + and mounted_example_contents.startswith("#!/usr/bin/env -S uv run python") + ), + details=mounted_example_path, + ), + ExampleCheck( + name="uploaded object shows TTL and compression details", + passed=( + archive_info.content_type == "tgz" + and archive_info.delete_after_time_ms is not None + and archive_info.delete_after_time_ms > archive_info.create_time_ms + ), + details=( + f"content_type={archive_info.content_type}, " + f"delete_after_time_ms={archive_info.delete_after_time_ms}" + ), + ), + ExampleCheck( + name="object mount preserved the bootstrap README content", + passed="uploaded with upload_from_dir()" in mounted_readme_contents, + details=mounted_readme_path, + ), + ], + ) + + +run_devbox_mounts_example = wrap_recipe(recipe) + + +if __name__ == "__main__": + run_as_cli(run_devbox_mounts_example) diff --git a/examples/registry.py b/examples/registry.py index edc52f445..b09fd331c 100644 --- a/examples/registry.py +++ b/examples/registry.py @@ -7,6 +7,7 @@ from typing import Any, Callable, cast +from .devbox_mounts import run_devbox_mounts_example from .devbox_tunnel import run_devbox_tunnel_example from .example_types import ExampleResult from .mcp_github_tools import run_mcp_github_tools_example @@ -32,6 +33,13 @@ "required_env": ["RUNLOOP_API_KEY"], "run": run_devbox_from_blueprint_lifecycle_example, }, + { + "slug": "devbox-mounts", + "title": "Devbox Mounts (Agent, Code, Object)", + "file_name": "devbox_mounts.py", + "required_env": ["RUNLOOP_API_KEY", "ANTHROPIC_API_KEY"], + "run": run_devbox_mounts_example, + }, { "slug": "devbox-snapshot-resume", "title": "Devbox Snapshot and Resume", diff --git a/llms.txt b/llms.txt index 3785def20..77619078a 100644 --- a/llms.txt +++ b/llms.txt @@ -11,6 +11,7 @@ ## Core Patterns - [Devbox lifecycle example](examples/devbox_from_blueprint_lifecycle.py): Create blueprint, launch devbox, run commands, cleanup +- [Devbox mounts example](examples/devbox_mounts.py): Combine agent, code, and object mounts while routing Anthropic access through agent gateway - [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 From 47007cd757b01221bfad25cff7595351ec5e4362 Mon Sep 17 00:00:00 2001 From: James Chainey Date: Mon, 23 Mar 2026 17:06:49 -0700 Subject: [PATCH 2/2] lint --- examples/devbox_mounts.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/examples/devbox_mounts.py b/examples/devbox_mounts.py index 7cf510cca..dddda7843 100644 --- a/examples/devbox_mounts.py +++ b/examples/devbox_mounts.py @@ -31,8 +31,6 @@ --- """ -from __future__ import annotations - import os import shlex import shutil @@ -42,7 +40,9 @@ from datetime import timedelta from runloop_api_client import AsyncRunloopSDK +from runloop_api_client.sdk.async_devbox import AsyncDevbox from runloop_api_client.sdk.async_storage_object import AsyncStorageObject +from runloop_api_client.sdk.async_execution_result import AsyncExecutionResult from ._harness import run_as_cli, unique_name, wrap_recipe from .example_types import ExampleCheck, RecipeOutput, RecipeContext @@ -88,7 +88,7 @@ async def ensure_claude_code_agent(sdk: AsyncRunloopSDK) -> tuple[str, bool]: return agent.id, False -def create_bootstrap_dir() -> tuple[Path, Path]: +def create_bootstrap_dir() -> Path: """Create local files that will be uploaded and extracted via object mount.""" temp_dir = Path(tempfile.mkdtemp(prefix="runloop-devbox-mounts-")) copied_example_path = temp_dir / COPIED_EXAMPLE_FILE_NAME @@ -97,12 +97,12 @@ def create_bootstrap_dir() -> tuple[Path, Path]: "This directory was uploaded with upload_from_dir(), stored as a tgz object, " "and extracted onto the devbox via an object mount.\n" ) - return temp_dir, copied_example_path + return temp_dir -async def discover_code_mount_path(devbox) -> str: +async def discover_code_mount_path(devbox: AsyncDevbox) -> str: """Find the repository path created by the code mount.""" - result = await devbox.cmd.exec( + result: AsyncExecutionResult = await devbox.cmd.exec( "if [ -d /home/user/rl-cli ]; then printf /home/user/rl-cli; " "elif [ -d /home/user/rl-clis ]; then printf /home/user/rl-clis; " "else exit 1; fi" @@ -141,7 +141,7 @@ async def recipe(ctx: RecipeContext) -> RecipeOutput: resources_created.append(f"secret:{secret.name}") cleanup.add(f"secret:{secret.name}", secret.delete) - bootstrap_dir, copied_example_path = create_bootstrap_dir() + bootstrap_dir = create_bootstrap_dir() cleanup.add(f"temp_dir:{bootstrap_dir}", lambda: shutil.rmtree(bootstrap_dir, ignore_errors=True)) archive: AsyncStorageObject = await sdk.storage_object.upload_from_dir( @@ -204,9 +204,7 @@ async def recipe(ctx: RecipeContext) -> RecipeOutput: # claude_prompt_result = await devbox.cmd.exec(claude_gateway_command) repo_mount_path = await discover_code_mount_path(devbox) - repo_package_json = ( - await devbox.file.read(file_path=f"{repo_mount_path}/package.json") if repo_mount_path else "" - ) + repo_package_json = await devbox.file.read(file_path=f"{repo_mount_path}/package.json") if repo_mount_path else "" mounted_example_path = f"{OBJECT_MOUNT_DIR}/{COPIED_EXAMPLE_FILE_NAME}" mounted_example_contents = await devbox.file.read(file_path=mounted_example_path) @@ -251,7 +249,7 @@ async def recipe(ctx: RecipeContext) -> RecipeOutput: ExampleCheck( name="object mount extracted the uploaded example file onto the devbox", passed=( - 'title: Devbox Mounts (Agent, Code, Object)' in mounted_example_contents + "title: Devbox Mounts (Agent, Code, Object)" in mounted_example_contents and mounted_example_contents.startswith("#!/usr/bin/env -S uv run python") ), details=mounted_example_path,