From a346fd2d981d34e42f8c65e05282e3688cd75532 Mon Sep 17 00:00:00 2001 From: "openhands-release-bot[bot]" <290150379+openhands-release-bot[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:51:44 +0000 Subject: [PATCH] Enterprise Cookbook preview: OpenHands/enterprise-cookbook#18 (do not merge) Preview of the Enterprise Cookbook pages produced by https://github.com/OpenHands/enterprise-cookbook/pull/18 (Point docs links at docs.openhands.dev), rendered from OpenHands/enterprise-cookbook@126c20e4a3becac898c541aac120277a9c2835b3. **Do not merge.** This draft exists only to get a Mintlify preview. It is updated on every push to the source PR and closed when that PR closes. Merged changes reach the docs through a separate `cookbook-sync` PR. _Opened automatically by the docs-preview workflow in OpenHands/enterprise-cookbook._ --- cookbook/archive-sandbox.mdx | 476 ++++++++++++++++++++++++ cookbook/clone-and-attach.mdx | 172 +++++++++ cookbook/command-blacklist.mdx | 22 +- cookbook/conversation-tags.mdx | 8 +- cookbook/gpg-commit-signing.mdx | 194 ++++++++++ cookbook/index.mdx | 42 ++- cookbook/per-conversation-secrets.mdx | 448 ++++++++++++++++++++++ cookbook/service-account-github-pat.mdx | 283 ++++++++++++++ cookbook/start-sandbox.mdx | 136 +++++++ docs.json | 16 + 10 files changed, 1779 insertions(+), 18 deletions(-) create mode 100644 cookbook/archive-sandbox.mdx create mode 100644 cookbook/clone-and-attach.mdx create mode 100644 cookbook/gpg-commit-signing.mdx create mode 100644 cookbook/per-conversation-secrets.mdx create mode 100644 cookbook/service-account-github-pat.mdx create mode 100644 cookbook/start-sandbox.mdx diff --git a/cookbook/archive-sandbox.mdx b/cookbook/archive-sandbox.mdx new file mode 100644 index 000000000..bd10d639f --- /dev/null +++ b/cookbook/archive-sandbox.mdx @@ -0,0 +1,476 @@ +--- +title: Archive Sandbox +description: Delete conversations to release their Persistent Volume Claims (PVCs) and free storage resources. +icon: box-archive +--- + +{/* GENERATED from OpenHands/enterprise-cookbook@126c20e4a3becac898c541aac120277a9c2835b3 (archive-sandbox/README.md). Edit the source, not this file. */} + + + +This example demonstrates how to properly archive/delete OpenHands conversations to release their Persistent Volume Claims (PVCs) and free up storage resources. + +When you create an OpenHands conversation, the system provisions a **sandbox** (also called a "runtime") that includes: + +- **Kubernetes Pod** - Container running the agent-server +- **Kubernetes Service** - Network access to the pod +- **Persistent Volume Claim (PVC)** - Storage for the workspace files +- **Ingress/HTTPRoute** - External routing +- **ServiceAccount, PodDisruptionBudget** - Supporting K8s resources + +The PVC persists even when a conversation is paused or stopped, allowing you to resume work later. However, PVCs consume storage quota and incur costs. To release these resources, you need to **delete the conversation**, which triggers full sandbox cleanup. + +## How It Works + +Based on the [runtime-api](https://github.com/All-Hands-AI/runtime-api) implementation, here's what happens when you delete a conversation: + +### 1. Conversation Deletion (`DELETE /api/v1/app-conversations/{conversation_id}`) + +The OpenHands Cloud API: + +- Marks the conversation as deleted in the database +- Checks if any other conversations share the same sandbox +- If no other conversations use the sandbox, triggers sandbox cleanup + +### 2. Sandbox Cleanup (Runtime API) + +The runtime API's `delete_runtime_and_workspace_in_k8s()` function removes all Kubernetes resources: + +```python +# From runtime-api/k8s.py +def delete_runtime_and_workspace_in_k8s(runtime_id, pod_id: str | None = None): + """Delete all K8s resources for a runtime.""" + + # Resources deleted in order: + # 1. Deployment + # 2. ServiceAccount + # 3. Service + # 4. PersistentVolumeClaim ← This releases the storage! + # 5. PodDisruptionBudget + # 6. HTTPRoute/Ingress + # 7. VolumeSnapshot (if any) +``` + +**Key Point**: The PVC deletion is what actually releases the storage. Once the PVC is deleted: + +- The underlying PersistentVolume (PV) is freed +- Storage quota is released +- Associated costs stop accumulating + +### 3. Resource Lifecycle States + +| State | Pod | PVC | Storage Released? | +| ----------- | ------------- | ---------- | ----------------- | +| **RUNNING** | ✅ Running | ✅ Exists | ❌ No | +| **PAUSED** | ❌ Scaled to 0 | ✅ Exists | ❌ No | +| **STOPPED** | ❌ Deleted | ✅ Exists\* | ❌ No | +| **DELETED** | ❌ Deleted | ❌ Deleted | ✅ **Yes** | + +\* For standard (non-fuse) runtimes, the PVC persists when stopped to allow resuming. A VolumeSnapshot may be taken before deletion for archival purposes. + +## Prerequisites + +```bash +# Set your OpenHands API key +export OH_API_KEY="your-api-key-here" + +# Install dependencies +pip install -r requirements.txt +# or +pip install requests +``` + +## Run It + +### Quick Start: Complete Workflow Demo + +Run the complete workflow example to see the entire process: + +```bash +python example_create_and_archive.py +``` + +This will: + +1. Create a new test conversation (provisions sandbox + PVC) +2. Show conversation details (sandbox_id, status, etc.) +3. Ask for confirmation to archive +4. Delete the conversation (releases PVC) +5. Verify deletion + +Perfect for understanding the complete lifecycle! + +### Force Immediate PVC Cleanup + +If you need to release PVCs **immediately** without waiting for the cleanup cronjob, use the sandbox DELETE endpoint directly: + +```bash +# List all sandboxes +python force_cleanup.py --list + +# Force delete a specific sandbox (immediate PVC release) +python force_cleanup.py + +# Force delete all idle (PAUSED/STOPPED) sandboxes +python force_cleanup.py --cleanup-idle +``` + +**Key Difference**: + +- **Conversation DELETE** (`archive_sandbox.py`) - Deletes conversation, cleans up sandbox only if no other conversations use it +- **Sandbox DELETE** (`force_cleanup.py`) - **Forces immediate cleanup** of all K8s resources including PVC, regardless of conversation state + +The sandbox DELETE endpoint directly calls the runtime-api's `stop_runtime()` function, which immediately executes `delete_runtime_and_workspace_in_k8s()`. This bypasses the cleanup cronjob and releases the PVC right away. + +**When to use force cleanup**: + +- ✅ You need immediate storage release +- ✅ You're cleaning up test/development sandboxes +- ✅ You've verified no important data remains +- ⚠️ **Warning**: This deletes the sandbox even if conversations still reference it! + +### List All Conversations + +```bash +python archive_sandbox.py list +``` + +Output shows conversation details including: + +- Conversation ID +- Title +- Sandbox ID (the runtime ID) +- Status (RUNNING, STOPPED, etc.) +- Cost metrics + +### Archive a Specific Conversation + +```bash +python archive_sandbox.py archive +``` + +This will: + +1. Show conversation details +2. Ask for confirmation +3. Delete the conversation +4. Trigger sandbox cleanup (if no other conversations use it) +5. Release the PVC + +Example: + +```bash +$ python archive_sandbox.py archive abc123def456 + +Archiving conversation abc123def456... + +Conversation details: + ID: abc123def456 + Title: My Test Conversation + Sandbox ID: m9dEVO2bDqar86rIaix93 + Sandbox Status: STOPPED + Execution Status: stopped + Created: 2024-01-15T10:30:00Z + Updated: 2024-01-15T11:45:00Z + Cost: $0.4339 + +This will delete the sandbox 'm9dEVO2bDqar86rIaix93' and release its PVC. + +Are you sure you want to delete this conversation? (yes/no): yes + +✓ Successfully deleted conversation abc123def456 +Response: {'success': True} + +The sandbox and its PVC have been cleaned up (if no other conversations use it). +``` + +### Bulk Cleanup Stopped Conversations + +```bash +python archive_sandbox.py cleanup +``` + +This will: + +1. Find all stopped conversations +2. Show a summary +3. Ask for confirmation +4. Archive all stopped conversations +5. Release their PVCs + +This is useful for cleaning up after testing or development. + +## Important Concepts + +### Warm Runtimes and PVC Types + +OpenHands supports two types of sandboxes: + +#### Standard (PVC-backed) + +- PVC provisioned at startup +- Workspace stored on persistent disk +- PVC persists through pause/resume +- **Must delete conversation to release PVC** + +#### Fuse/Dormant (S3-backed) + +- No PVC provisioned +- Workspace stored in S3 via fusey +- Mounted dynamically at claim time +- Fast resume without PVC snapshots +- **No PVC cleanup needed** - just deletes S3 objects + +### Cleanup Cronjob + +The runtime-api runs a cleanup cronjob (`cleanup.py`) every 5 minutes that: + +1. **Cleanup stuck PVCs** - Removes PVCs that never bound +2. **Cleanup terminated pods** - Removes pods with no DB record +3. **Pause idle runtimes** - Pauses runtimes idle > 30 min (configurable) +4. **Snapshot and delete idle PVCs** - For paused standard runtimes +5. **Delete old fuse workspaces** - Purges S3 objects for stopped fuse runtimes > 30 days +6. **Delete old runtimes** - Removes K8s resources for runtimes stopped > 1 day + +**Important**: The cleanup cronjob does NOT delete PVCs for active or recently stopped conversations. You must explicitly delete the conversation to trigger immediate cleanup. + +## Files in This Example + +- **`archive_sandbox.py`** - Main CLI tool for archiving conversations +- **`force_cleanup.py`** - Force immediate PVC cleanup by deleting sandbox directly +- **`example_create_and_archive.py`** - Complete workflow demonstration +- **`requirements.txt`** - Python dependencies +- **`README.md`** - This documentation + +## Best Practices + +### When to Archive + +✅ **Do archive**: + +- Test conversations you no longer need +- Failed or error conversations +- Completed one-off tasks +- Conversations stopped for > 7 days + +❌ **Don't archive**: + +- Active conversations (status: RUNNING) +- Conversations you plan to resume soon +- Conversations with important work not yet backed up + +### Before Archiving + +1. **Download important files** from the workspace +2. **Export conversation history** if needed (use the download endpoint) +3. **Check for workspace archives** - OpenHands may automatically archive workspaces before pause (see `tags.archiveworkspacepath`) + +### Workspace Archiving (Automatic) + +OpenHands can automatically archive workspace contents before pausing/stopping: + +```python +# Environment variable controls this feature +RUNTIME_FILE_ARCHIVE_ENABLED=true # Enable workspace archiving +RUNTIME_FILE_ARCHIVE_FORMATS=tar.gz,git-delta # Archive formats +RUNTIME_FILE_ARCHIVE_PREFIX=workspace-archives # S3 prefix +``` + +If enabled, the cleanup process will: + +1. Download workspace as tar.gz or git-delta +2. Upload to S3 with manifest +3. Tag conversation with archive path +4. Then delete the PVC + +Check `conversation.tags.archiveworkspacepath` to see if an archive exists. + +## Monitoring and Verification + +### Check Kubernetes Resources + +If you have kubectl access to the runtime cluster: + +```bash +# List all runtime pods +kubectl get pods -n runtime-pods + +# List all PVCs +kubectl get pvc -n runtime-pods + +# Check a specific runtime's resources +kubectl get all,pvc -n runtime-pods -l runtime_id= +``` + +After deleting a conversation, these resources should be removed. + +### Check via API + +```bash +# List your conversations +curl -H "Authorization: Bearer $OH_API_KEY" \ + "https://app.all-hands.dev/api/v1/app-conversations/search?limit=100" | jq + +# Get specific conversation (returns 404 if deleted) +curl -H "Authorization: Bearer $OH_API_KEY" \ + "https://app.all-hands.dev/api/v1/app-conversations/" | jq +``` + +## Example: Full Workflow + +Here's a complete example of creating and archiving a sandbox: + +```python +import os +import requests + +API_BASE = "https://app.all-hands.dev" +API_KEY = os.getenv("OH_API_KEY") +headers = {"Authorization": f"Bearer {API_KEY}"} + +# 1. Create a conversation (creates sandbox automatically) +response = requests.post( + f"{API_BASE}/api/v1/app-conversations/stream-start", + headers=headers, + json={ + "llm_model": "claude-sonnet-4-5-20250929", + "agent_kind": "openhands", + } +) +conv_data = response.json() +conversation_id = conv_data["id"] +sandbox_id = conv_data["sandbox_id"] + +print(f"Created conversation {conversation_id}") +print(f"Sandbox ID: {sandbox_id}") + +# 2. Do some work... +# (send messages, run commands, etc.) + +# 3. Archive when done +response = requests.delete( + f"{API_BASE}/api/v1/app-conversations/{conversation_id}", + headers=headers +) + +print(f"Archived conversation {conversation_id}") +print(f"PVC for sandbox {sandbox_id} has been released") +``` + +## Troubleshooting + +### "Conversation not found" error + +The conversation may already be deleted. This is safe to ignore. + +### PVC still exists after deletion + +Possible reasons: + +1. **Another conversation uses the same sandbox** - The sandbox is shared +2. **Cleanup not yet run** - K8s deletion is asynchronous, may take a few seconds +3. **Kubernetes finalizers** - PV/PVC may have finalizers delaying deletion + +Check: + +```bash +kubectl get pvc -n runtime-pods -o yaml | grep -A 5 finalizers +``` + +### Can't delete running conversation + +You can delete running conversations - the API will stop them first. However, it's cleaner to stop them explicitly: + +```bash +# Stop first (optional) +curl -X POST \ + -H "Authorization: Bearer $OH_API_KEY" \ + "https://app.all-hands.dev/api/organizations//conversations//stop" + +# Then delete +python archive_sandbox.py archive +``` + +## Summary + +**Key Takeaways**: + +1. ✅ **Two ways to release PVCs**: + - **Conversation DELETE** - Deletes conversation, cleans up sandbox if not shared + - **Sandbox DELETE** - Force immediate cleanup, always releases PVC +2. ✅ **No runtime-api access needed** - Use enterprise-server API with `OH_API_KEY` +3. ✅ **Pausing/Stopping preserves the PVC** - For resuming work later +4. ✅ **Force cleanup for immediate release** - Use `force_cleanup.py` to bypass cronjob +5. ✅ **Bulk cleanup available** - Archive all stopped conversations/sandboxes at once +6. ✅ **Automatic workspace archiving** - May preserve files before PVC deletion +7. ✅ **Check before deleting** - Download important files first + +**Quick Decision Guide**: + +| Scenario | Use This | Command | +| ---------------------------------- | ------------------------- | --------------------------------------------- | +| Clean up one finished conversation | Conversation DELETE | `python archive_sandbox.py archive ` | +| Need immediate storage release | Sandbox DELETE | `python force_cleanup.py ` | +| Clean up all stopped conversations | Bulk conversation cleanup | `python archive_sandbox.py cleanup` | +| Clean up all idle sandboxes | Bulk sandbox cleanup | `python force_cleanup.py --cleanup-idle` | + +When in doubt, remember: **DELETE sandbox → Immediate PVC release → Storage freed instantly** ✨ + +## APIs Used + +### Enterprise-Server API (OpenHands Cloud) + +You can use these endpoints with just your `OH_API_KEY` - **no direct runtime-api access required**: + +| Endpoint | Method | Purpose | PVC Cleanup | +| ------------------------------------------------------------------ | ------ | -------------------- | -------------------------------------------- | +| `/api/v1/app-conversations/{conversation_id}` | DELETE | Delete conversation | ✅ Yes, if no other conversations use sandbox | +| `/api/v1/sandboxes/{sandbox_id}` | DELETE | Force delete sandbox | ✅ **Immediate** - always deletes PVC | +| `/api/v1/sandboxes/{sandbox_id}/pause` | POST | Pause sandbox | ❌ No - preserves PVC | +| `/api/organizations/{org_id}/conversations/{conversation_id}/stop` | POST | Stop conversation | ❌ No - preserves PVC | + +**Key Insight**: Both conversation DELETE and sandbox DELETE work through the enterprise-server API. You do **NOT** need direct runtime-api access to force PVC cleanup! + +### How It Works + +```mermaid +flowchart LR + User -->|OH_API_KEY| ES[Enterprise-Server API] + ES -->|internal| RA[Runtime-API] + RA -->|PVC deletion| K8s[Kubernetes] +``` + +When you call `DELETE /api/v1/sandboxes/{sandbox_id}`: + +1. Enterprise-Server validates your API key +2. Calls runtime-api's `/stop` endpoint internally +3. Runtime-api executes `delete_runtime_and_workspace_in_k8s()` +4. PVC is immediately deleted from Kubernetes + +### Runtime-API Direct Access (Optional) + +If you have direct runtime-api access (e.g., via port-forward for testing), you can also use: + +| Endpoint | Method | Purpose | +| -------- | ------ | ------------------------------------- | +| `/stop` | POST | Stop runtime and delete all resources | +| `/pause` | POST | Pause runtime (keeps PVC) | +| `/list` | GET | List all runtimes | + +However, **in production you should use the enterprise-server API** (sandbox DELETE) which provides proper authentication, rate limiting, and audit logging. + +## Related + + + + The service managing sandboxes + + + + Full API reference + + + + Persistent volume concepts + + diff --git a/cookbook/clone-and-attach.mdx b/cookbook/clone-and-attach.mdx new file mode 100644 index 000000000..876b75659 --- /dev/null +++ b/cookbook/clone-and-attach.mdx @@ -0,0 +1,172 @@ +--- +title: Clone and Attach +description: Clone a repository and run its setup script in a sandbox, then attach a conversation to the prepared environment. +icon: code-branch +--- + +{/* GENERATED from OpenHands/enterprise-cookbook@126c20e4a3becac898c541aac120277a9c2835b3 (clone-and-attach/README.md). Edit the source, not this file. */} + + + +This example provisions a sandbox **yourself** — shallow-cloning a git repo and +running its setup script — and only *then* hands it to an OpenHands agent by +**attaching a conversation** to that already-prepared sandbox. + +It builds directly on [`start-sandbox`](/cookbook/start-sandbox), which shows the bare +sandbox lifecycle. Read that one first if the sandbox/agent-server split is new +to you. + +## How It Works + +```mermaid +sequenceDiagram + participant You + participant Cloud as Cloud app server + participant Agent as Sandbox agent server + You->>Cloud: POST /api/v1/sandboxes (1. start a sandbox, no conversation) + You->>Cloud: GET /api/v1/sandboxes?id=#lt;id#gt; (2. poll until status == RUNNING) + You->>Agent: POST /api/bash/execute_bash_command (3. git clone --depth 1 #lt;repo#gt;) + You->>Agent: POST /api/bash/execute_bash_command (4. bash .openhands/setup.sh) + You->>Cloud: POST /api/v1/app-conversations (5. attach a conversation, sandbox_id=#lt;id#gt;) + You->>Cloud: GET /api/v1/app-conversations/start-tasks (5b. poll for the app_conversation_id) +``` + +Steps 1–2 use the **Cloud app server** (auth header `X-Session-API-Key: `). +Steps 3–4 use the sandbox's **agent server** (auth header +`X-Session-API-Key: `, returned by the create call). Step 5 is +back on the Cloud app server. See [`start-sandbox`](/cookbook/start-sandbox) for more +on the two-server split. + +### Where does `setup.sh` live? + +In the repository, at `.openhands/setup.sh`. That is the exact location +OpenHands itself runs every time it starts working with a repo — see +[Repository Customization](/openhands/usage/customization/repository). +This example runs that same file so the sandbox you hand off is set up the way +the agent would expect. If a repo has no `.openhands/setup.sh`, the step is +skipped with a note. (This repo ships a tiny one so the default run does +something visible.) + +### Attaching is asynchronous + +`POST /api/v1/app-conversations` returns a **start task**, not the conversation +itself. Poll `GET /api/v1/app-conversations/start-tasks?ids=` until it +reports an `app_conversation_id`, then open +`https://app.all-hands.dev/conversations/`. + +## Run It + +```bash +export OH_API_KEY=... # your https://app.all-hands.dev API key +pip install requests + +# Zero-config: clones this repo (it has a .openhands/setup.sh) and attaches +# a conversation that summarizes it. +python attach_conversation.py +``` + +Sample output: + +```text +sandbox: 1ho9eZpt4m27CC23XPdGcN + sandbox status: RUNNING +agent: https://ahhygodzefollslv.prod-runtime.all-hands.dev + +=== shallow clone https://github.com/OpenHands/enterprise-cookbook -> /workspace/enterprise-cookbook === +$ git clone (exit=0) + +=== run .openhands/setup.sh === +$ setup script (exit=0) +[enterprise-cookbook setup.sh] running in /workspace/enterprise-cookbook +[enterprise-cookbook setup.sh] python: Python 3.13.13 +[enterprise-cookbook setup.sh] done + +=== attach conversation === + start-task status: STARTING_CONVERSATION + start-task status: READY + +Conversation attached to your prepared sandbox: + https://app.all-hands.dev/conversations/f041a2e252cf45b39a46a3189b2efce7 +``` + +Open that URL and you'll find the agent already in a workspace where your repo +is cloned and set up. + +## Why Would I Do This? + +Normally you start a conversation and OpenHands clones your selected repository +for you. Sometimes you want more control *before* the agent gets involved: + +- pre-warm an environment so the agent starts instantly on an expensive setup, +- check out a specific commit, tag, or a sub-path of a monorepo, +- clone from a mirror or run custom bootstrapping the default flow doesn't do, +- reuse one prepared sandbox for several scripted conversations. + +The trick is a single field: `POST /api/v1/app-conversations` accepts a +`sandbox_id`. Pass the id of a sandbox you already prepared and the new +conversation attaches to it instead of creating a fresh one. + +## Point It at Your Own Repo + +Every input is a flag with an environment-variable fallback, so the script is +safe to drop into your own automation unchanged: + +| Flag | Env var | Default | Purpose | +| ------------------- | ----------------- | --------------------------- | ----------------------------------------------- | +| `--api-key` | `OH_API_KEY` | — (required) | Cloud API key | +| `--base-url` | `OH_API_BASE` | `https://app.all-hands.dev` | Cloud app server | +| `--repo` | `REPO_URL` | this repo | Git URL to shallow-clone | +| `--branch` | `REPO_BRANCH` | repo default | Branch to check out | +| `--depth` | `CLONE_DEPTH` | `1` | `git clone --depth` | +| `--workdir` | `WORKDIR` | `/workspace` | Where the repo is cloned | +| `--setup-script` | `SETUP_SCRIPT` | `.openhands/setup.sh` | Script to run after clone | +| `--message` | `INITIAL_MESSAGE` | a summarize prompt | First message to the agent | +| `--sandbox-id` | `SANDBOX_ID` | none | Reuse a RUNNING sandbox instead of creating one | +| `--sandbox-spec-id` | `SANDBOX_SPEC_ID` | account default | Runtime image to start | +| `--poll-timeout` | `POLL_TIMEOUT` | `240` | Seconds to wait for readiness | + +```bash +python attach_conversation.py \ + --repo https://github.com/your-org/your-repo \ + --branch main \ + --message "Run the test suite and fix any failures." +``` + +> Cloning a **private** repo? Start the sandbox with the appropriate git +> credentials available (e.g. via sandbox secrets) or clone over an +> authenticated URL. This example targets public repositories to stay simple. + +## Cleanup + +The sandbox is intentionally left running because a live conversation is now +attached to it — deleting the sandbox ends that conversation. Delete it from the +conversation UI, or via the API (the id goes in **both** the path and a required +`sandbox_id` query parameter): + +```bash +SID= +curl -X DELETE "https://app.all-hands.dev/api/v1/sandboxes/${SID}?sandbox_id=${SID}" \ + -H "X-Session-API-Key: $OH_API_KEY" +``` + +## APIs Used + +| Endpoint | Method | Purpose | +| --------------------------------------- | ------ | -------------------------------------------------------- | +| `/api/v1/sandboxes` | POST | Start a sandbox (no conversation) | +| `/api/v1/sandboxes` | GET | Poll until status == RUNNING | +| `{agent}/api/bash/execute_bash_command` | POST | Run `git clone` and `.openhands/setup.sh` in the sandbox | +| `/api/v1/app-conversations` | POST | Attach a conversation (`sandbox_id`) | +| `/api/v1/app-conversations/start-tasks` | GET | Poll for the `app_conversation_id` | + +## Related + + + + The bare sandbox lifecycle and the sandbox/agent-server split + + + + Where .openhands/setup.sh lives + + diff --git a/cookbook/command-blacklist.mdx b/cookbook/command-blacklist.mdx index e68a1855f..a59b71cd8 100644 --- a/cookbook/command-blacklist.mdx +++ b/cookbook/command-blacklist.mdx @@ -1,12 +1,12 @@ --- -title: Command blacklist +title: Command Blacklist description: Block known-dangerous shell commands with a PreToolUse hook bundled in a plugin. Everything not on the blocklist runs normally. icon: shield-halved --- -{/* GENERATED from OpenHands/enterprise-cookbook@main (command-blacklist/README.md). Edit the source, not this file. */} +{/* GENERATED from OpenHands/enterprise-cookbook@126c20e4a3becac898c541aac120277a9c2835b3 (command-blacklist/README.md). Edit the source, not this file. */} - + A self-contained example showing how to use **PreToolUse hooks** in a plugin to **blacklist dangerous shell commands**. When the agent tries to execute a risky command, the hook blocks it with helpful (and slightly snarky) feedback. @@ -14,7 +14,7 @@ This example demonstrates the **blacklist approach**: block known dangerous patt ## What's in the Box -The [`safety-guardian/`](https://github.com/OpenHands/enterprise-cookbook/tree/main/command-blacklist/safety-guardian) plugin bundles: +The [`safety-guardian/`](https://github.com/OpenHands/enterprise-cookbook/tree/126c20e4a3becac898c541aac120277a9c2835b3/command-blacklist/safety-guardian) plugin bundles: - **Hooks** (`hooks/hooks.json`) - PreToolUse hook that intercepts terminal commands - **Skill** (`skills/safety-guardian/SKILL.md`) - Documentation about what's protected @@ -56,11 +56,11 @@ All other commands work normally - only these specific dangerous patterns are bl (So `rm -rf /tmp` is **not** blocked; use the `curl … | bash` demo below to see a block.) -## Try It +## Run It - Use the companion [`load-plugin`](https://github.com/OpenHands/enterprise-cookbook/tree/main/load-plugin) example: + Use the companion [`load-plugin`](https://github.com/OpenHands/enterprise-cookbook/tree/126c20e4a3becac898c541aac120277a9c2835b3/load-plugin) example: ```bash cd ../load-plugin @@ -96,7 +96,7 @@ All other commands work normally - only these specific dangerous patterns are bl ## The Hook -The magic happens in [`hooks/hooks.json`](https://github.com/OpenHands/enterprise-cookbook/blob/main/command-blacklist/safety-guardian/hooks/hooks.json): +The magic happens in [`hooks/hooks.json`](https://github.com/OpenHands/enterprise-cookbook/blob/126c20e4a3becac898c541aac120277a9c2835b3/command-blacklist/safety-guardian/hooks/hooks.json): ```json safety-guardian/hooks/hooks.json { @@ -156,7 +156,7 @@ This example uses a **blacklist** approach: - ❌ **Con:** Can't catch every dangerous pattern - ❌ **Con:** Clever variations might slip through -For high-security scenarios, see the companion [`command-whitelist`](https://github.com/OpenHands/enterprise-cookbook/tree/main/command-whitelist) example that shows the **whitelist** approach (only allow explicitly approved commands). +For high-security scenarios, see the companion [`command-whitelist`](https://github.com/OpenHands/enterprise-cookbook/tree/126c20e4a3becac898c541aac120277a9c2835b3/command-whitelist) example that shows the **whitelist** approach (only allow explicitly approved commands). ## Hook Types @@ -201,15 +201,15 @@ This follows the **Claude Code plugin format**, compatible with: How plugins work - + Programmatic plugin loading - + No-code plugin launcher - + Whitelist approach (opposite strategy) diff --git a/cookbook/conversation-tags.mdx b/cookbook/conversation-tags.mdx index c6868c431..70ab6932c 100644 --- a/cookbook/conversation-tags.mdx +++ b/cookbook/conversation-tags.mdx @@ -1,12 +1,12 @@ --- -title: Conversation tags +title: Conversation Tags description: Attach key-value metadata to a conversation with tags and read it back from AppConversation.tags. icon: tags --- -{/* GENERATED from OpenHands/enterprise-cookbook@main (conversation-tags/README.md). Edit the source, not this file. */} +{/* GENERATED from OpenHands/enterprise-cookbook@126c20e4a3becac898c541aac120277a9c2835b3 (conversation-tags/README.md). Edit the source, not this file. */} - + Stash your own key-value metadata on an OpenHands conversation — for example an external `environment_url` or `environment_conversation_id` — and read it back @@ -49,7 +49,7 @@ and then *polls* the Cloud read instead of reading once. > the agent server is the authoritative place to write them, and the Cloud > reflects the result. The agent `POST /api/conversations` also accepts `tags` > at creation time if you provision the sandbox yourself (see -> [`clone-and-attach`](https://github.com/OpenHands/enterprise-cookbook/tree/main/clone-and-attach)). +> [`clone-and-attach`](/cookbook/clone-and-attach)). ## Tag rules diff --git a/cookbook/gpg-commit-signing.mdx b/cookbook/gpg-commit-signing.mdx new file mode 100644 index 000000000..69ce380f4 --- /dev/null +++ b/cookbook/gpg-commit-signing.mdx @@ -0,0 +1,194 @@ +--- +title: GPG Commit Signing +description: Configure GPG commit signing on every conversation with a SessionStart hook that imports a key from a custom secret. +icon: file-signature +--- + +{/* GENERATED from OpenHands/enterprise-cookbook@126c20e4a3becac898c541aac120277a9c2835b3 (gpg-commit-signing/README.md). Edit the source, not this file. */} + + + +Configure **GPG commit signing at the start of every conversation** — not just +when a repository is selected — using a `SessionStart` hook bundled in a plugin. + +The [`gpg-signer/`](https://github.com/OpenHands/enterprise-cookbook/tree/126c20e4a3becac898c541aac120277a9c2835b3/gpg-commit-signing/gpg-signer) plugin imports a private key from a +[custom secret](#the-secret) and configures git to sign commits and tags +**globally**, so signing applies to any repo the agent clones during the +session. + +## The problem this solves + +Teams often configure signing today in `.openhands/setup.sh`: + +```bash +#!/bin/bash +set -euo pipefail +echo "$gpg_key" | gpg --import +KEY_ID=$(echo "$gpg_key" | gpg --show-keys --with-colons | awk -F: '/^fpr/{print $10; exit}') +git config user.signingkey "$KEY_ID" +git config commit.gpgsign true +git config user.name "$git_user_name" +git config user.email "$git_user_email" +``` + +But **`setup.sh` only runs when the conversation is started with a repository +selected.** Start a conversation with no repo (or have the agent clone a repo +later) and signing is silently missing. + +A **`SessionStart` hook runs at the beginning of *every* conversation**, +regardless of whether a repo was picked — exactly when you want signing set up. +That is the whole idea behind this example. + +## Why the hook can read the same secret as `setup.sh` + +On the agent-server, hook scripts and `setup.sh` execute against the **same +process environment**. A custom secret named `gpg_key` that `setup.sh` can read +as `$gpg_key` is visible to the `SessionStart` hook the same way — so the logic +you already run in `setup.sh` moves into the hook almost verbatim. + +Two differences from the raw `setup.sh` version, both deliberate: + +- **`git config --global`** instead of a bare `git config`. There is no + repository yet when `SessionStart` fires, and a global config applies to every + repo cloned afterwards. +- **Never fails the session.** `SessionStart` hooks cannot block, so on any + problem (missing secret, bad key) the hook logs the reason to + `/tmp/openhands_gpg_setup.log` and exits `0`. + +## The secret + +Add these under **Settings → Secrets** in OpenHands (or pass them per +conversation via the API): + +| Secret name | Contents | Required | +| ---------------- | ---------------------------------------------------------------------------------- | -------- | +| `gpg_key` | ASCII-armored **private** key — `gpg --armor --export-secret-keys you@example.com` | Yes | +| `git_user_name` | Git author name | Optional | +| `git_user_email` | Git author email (should match a key UID) | Optional | + +If `gpg_key` is absent the hook does nothing (and says so in the log), so it is +safe to load the plugin for everyone and let it activate only where a key is +configured. + +> Secret names are configurable through the `GPG_KEY_SECRET_NAME`, +> `GIT_USER_NAME_SECRET_NAME`, and `GIT_USER_EMAIL_SECRET_NAME` environment +> variables if your org uses different names. + +## What's in the box + +```text +gpg-signer/ +├── .claude-plugin/ +│ └── plugin.json # Plugin metadata +├── hooks/ +│ ├── hooks.json # SessionStart hook (inline script) +│ └── scripts/ +│ └── configure_gpg_signing.sh # Readable reference copy of the inline script +└── skills/ + └── gpg-signer/ + └── SKILL.md # Docs the agent reads (auto-loaded) +``` + +## The hook + +The logic lives in [`hooks/hooks.json`](https://github.com/OpenHands/enterprise-cookbook/blob/126c20e4a3becac898c541aac120277a9c2835b3/gpg-commit-signing/gpg-signer/hooks/hooks.json) under the +`SessionStart` event: + +```json +{ + "hooks": { + "SessionStart": [ + { + "matcher": "*", + "hooks": [ + { "type": "command", "command": "…import key + git config --global…", "timeout": 30 } + ] + } + ] + } +} +``` + +**How it works:** + +1. **`SessionStart`** — runs once, when the conversation begins (no repo needed). +2. Reads the armored key from `$gpg_key`, imports it with `gpg --batch --import`. +3. Extracts the primary key fingerprint (`gpg --show-keys --with-colons`). +4. Sets `user.signingkey`, `commit.gpgsign true`, `tag.gpgsign true`, and + `gpg.program` **globally**, plus the optional identity secrets. +5. Exits `0` always; all human-readable output goes to + `/tmp/openhands_gpg_setup.log` (on a successful hook, stdout is parsed as + JSON, so it is kept clean). + +> **Why the script is inlined (and mirrored in `hooks/scripts/`).** When this +> runs as a **plugin**, hooks execute with the working directory set to the +> agent's workspace (not the plugin directory), and there is no plugin-root path +> variable — so `command` **cannot** point at a bundled +> `hooks/scripts/*.sh`. The runnable copy is therefore inlined in `hooks.json`; +> [`hooks/scripts/configure_gpg_signing.sh`](https://github.com/OpenHands/enterprise-cookbook/blob/126c20e4a3becac898c541aac120277a9c2835b3/gpg-commit-signing/gpg-signer/hooks/scripts/configure_gpg_signing.sh) +> is a readable reference that mirrors it. Edit the reference first, then mirror +> it into `hooks.json`. (Same pattern as the +> [`workspace-isolation`](https://github.com/OpenHands/enterprise-cookbook/tree/126c20e4a3becac898c541aac120277a9c2835b3/workspace-isolation) example.) + +## Try it + +### Option 1: Load via API + +Use the companion [`load-plugin`](https://github.com/OpenHands/enterprise-cookbook/tree/126c20e4a3becac898c541aac120277a9c2835b3/load-plugin) example. Because the plugin +needs your key, pass it as a per-conversation secret: + +```bash +cd ../load-plugin +python load_plugin.py \ + --repo-path gpg-commit-signing/gpg-signer \ + --secret gpg_key="$(gpg --armor --export-secret-keys you@example.com)" \ + --secret git_user_name="Your Name" \ + --secret git_user_email="you@example.com" \ + --message "Show me the output of: git config --global --get commit.gpgsign and git config --global --get user.signingkey" + +# Expected: commit.gpgsign=true and user.signingkey= +``` + +> If you have already stored `gpg_key` as a user-level secret, the `--secret` +> flags are unnecessary — the hook will pick it up automatically. + +### Option 2: Launch via badge + +Store `gpg_key` as a user secret first (the badge can't carry your private key), +then click: + +[![Try GPG Signer](https://img.shields.io/badge/Try%20GPG%20Signer-blue)](https://app.all-hands.dev/launch?plugins=W3sic291cmNlIjogImdpdGh1YjpPcGVuSGFuZHMvZW50ZXJwcmlzZS1jb29rYm9vayIsICJyZWYiOiAibWFpbiIsICJyZXBvX3BhdGgiOiAiZ3BnLWNvbW1pdC1zaWduaW5nL2dwZy1zaWduZXIifV0%3D\&message=Show%20me%20the%20output%20of%3A%20git%20config%20--global%20--get%20commit.gpgsign%20and%20git%20config%20--global%20--get%20user.signingkey) + +> **Note:** Replace `ref: main` with your branch name if testing before merge. + +## Verifying a signed commit + +Once the hook has run, any commit is signed: + +```bash +git clone https://github.com/octocat/Hello-World && cd Hello-World +echo "test" >> README && git commit -am "signed test" +git log --show-signature -1 # -> "Good signature from ..." +``` + +## Hook types + +`SessionStart` is one of several lifecycle events you can hook. See the other +examples for blocking hooks: + +| Hook | When It Runs | Can Block? | Example | +| ---------------- | ------------------------------ | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| PreToolUse | Before tool execution | ✅ Yes (exit 2) | [`command-blacklist`](/cookbook/command-blacklist), [`command-whitelist`](https://github.com/OpenHands/enterprise-cookbook/tree/126c20e4a3becac898c541aac120277a9c2835b3/command-whitelist), [`workspace-isolation`](https://github.com/OpenHands/enterprise-cookbook/tree/126c20e4a3becac898c541aac120277a9c2835b3/workspace-isolation) | +| PostToolUse | After tool execution | ❌ No | Logging, metrics | +| UserPromptSubmit | Before processing user message | ✅ Yes | Content filtering | +| Stop | When agent tries to finish | ✅ Yes | Require artifacts | +| **SessionStart** | **When conversation starts** | ❌ No | **Environment setup (this example)** | +| SessionEnd | When conversation ends | ❌ No | Cleanup | + +## Related + +- [OpenHands Hooks Guide](/openhands/usage/customization/hooks) — repository `.openhands/hooks.json` format +- [Hooks (SDK guide)](/sdk/guides/hooks) — programmatic hooks +- [`load-plugin`](https://github.com/OpenHands/enterprise-cookbook/tree/126c20e4a3becac898c541aac120277a9c2835b3/load-plugin) — programmatic plugin loading (and passing per-conversation secrets) +- [`per-conversation-secrets`](/cookbook/per-conversation-secrets) — how secrets reach a conversation +- [`command-blacklist`](/cookbook/command-blacklist) / [`command-whitelist`](https://github.com/OpenHands/enterprise-cookbook/tree/126c20e4a3becac898c541aac120277a9c2835b3/command-whitelist) / [`workspace-isolation`](https://github.com/OpenHands/enterprise-cookbook/tree/126c20e4a3becac898c541aac120277a9c2835b3/workspace-isolation) — other hook examples diff --git a/cookbook/index.mdx b/cookbook/index.mdx index 7a062399b..d4064359c 100644 --- a/cookbook/index.mdx +++ b/cookbook/index.mdx @@ -3,29 +3,65 @@ title: Enterprise Cookbook description: Runnable examples for building on the OpenHands API with OpenHands Cloud or OpenHands Enterprise. --- -{/* GENERATED from OpenHands/enterprise-cookbook@main (cookbook.yaml). Edit the source, not this file. */} +{/* GENERATED from OpenHands/enterprise-cookbook@126c20e4a3becac898c541aac120277a9c2835b3 (cookbook.yaml). Edit the source, not this file. */} Standalone, runnable examples for teams building on the OpenHands API with OpenHands Cloud or OpenHands Enterprise. Each page is generated from an example in [OpenHands/enterprise-cookbook](https://github.com/OpenHands/enterprise-cookbook), where you will find the full source. +## Sandbox lifecycle + +Create, attach to, and tear down sandboxes. + + + + Delete conversations to release their Persistent Volume Claims (PVCs) and free storage resources. + + + + Clone a repository and run its setup script in a sandbox, then attach a conversation to the prepared environment. + + + + Start a sandbox without a conversation and run commands via the agent-server REST API. + + + ## Conversation monitoring & reacting Observe conversations and react to their state. - + Attach key-value metadata to a conversation with tags and read it back from AppConversation.tags. +## Secrets & authentication + +Inject credentials and manage identity. + + + + Configure GPG commit signing on every conversation with a SessionStart hook that imports a key from a custom secret. + + + + Inject per-conversation secrets via REST API as bash env vars and to template an MCP server config bundled in a plugin. + + + + Use one OpenHands account as a service account, overriding the managed GITHUB_TOKEN with each user's PAT per conversation. + + + ## Guardrails Constrain what the agent can do with hooks. - + Block known-dangerous shell commands with a PreToolUse hook bundled in a plugin. Everything not on the blocklist runs normally. diff --git a/cookbook/per-conversation-secrets.mdx b/cookbook/per-conversation-secrets.mdx new file mode 100644 index 000000000..ec8a44338 --- /dev/null +++ b/cookbook/per-conversation-secrets.mdx @@ -0,0 +1,448 @@ +--- +title: Per-Conversation Secrets +description: Inject per-conversation secrets via REST API as bash env vars and to template an MCP server config bundled in a plugin. +icon: user-secret +--- + +{/* GENERATED from OpenHands/enterprise-cookbook@126c20e4a3becac898c541aac120277a9c2835b3 (per-conversation-secrets/README.md). Edit the source, not this file. */} + + + +This example demonstrates how to inject per-conversation secrets into an OpenHands +conversation using only REST APIs (no WebSocket required), and — importantly — how +those secrets can be **expanded into a plugin's MCP server configuration** so the +agent can talk to a third-party MCP server with a token that lives only for the +lifetime of one conversation. + +## Why you'd want this + +OpenHands already lets users define secrets at the *user* level (stored in the +vault). That's fine for stable, long-lived credentials. But there are cases where +you want a secret that is: + +- **Scoped to a single conversation** — e.g., a customer's OAuth token, a + temporary CI credential, or a one-off API key the calling system minted for + this run. +- **Used to authenticate an MCP server**, not just exported as a bash variable — + e.g., wire the agent into a customer's Linear / GitHub / internal API by + passing the right `Authorization: Bearer …` header on every MCP call. + +This example shows both, and how they compose. + +## The two patterns shown here + +A single `secrets` field on the conversation-start request powers two related but +distinct patterns. **Read this section before anything else** — most of the +confusion in earlier versions of this README came from blurring them together. + +### Pattern A — Secrets as bash environment variables + +You pass `{"MY_KEY": "value"}` at conversation start (or inject it later via the +agent server's `/secrets` endpoint), and `$MY_KEY` becomes available to every +`bash` command the agent runs. + +- **Demonstrated by:** `test_secrets_at_start.py` (recommended) and + `test_secrets.py` (legacy / mid-conversation injection). +- **Use when:** the agent needs a credential to run a CLI command, hit an HTTP + API via `curl`, or otherwise consume a secret from the shell. + +### Pattern B — Secrets as `${VARIABLE}` placeholders inside a plugin's MCP config + +The same `secrets` field also feeds variable expansion in an OpenHands +**plugin**'s `.mcp.json`. A plugin is a small bundle of files (described below) +that OpenHands fetches from GitHub at conversation start; if its `.mcp.json` +contains `${MCP_SERVER_URL}` or `${MCP_SECRET_TOKEN}`, those placeholders are +filled in from the conversation's secrets *before* the MCP transport is dialed. + +- **Demonstrated by:** `test_mcp_secrets_at_start.py` (recommended) and + `test_mcp_secrets.py` (legacy). +- **Use when:** you want the agent to talk to an MCP server (yours or a + customer's) using a token that isn't in the user's vault — for example, a + per-tenant token chosen by your application for this conversation only. + +The rest of this document walks through Pattern B in detail because it's the +less obvious of the two. Pattern A is just "set an env var" — see the test +scripts for end-to-end examples. + +## What is a plugin (in this example)? + +A plugin is a directory in a git repo with three files. The whole `test-plugin/` +folder in this repo is a working example: + +```text +test-plugin/ +├── .mcp.json # MCP server registration WITH ${...} placeholders +├── .plugin/plugin.json # manifest: name, version, author +└── SKILL.md # human-readable doc the agent reads +``` + +**`test-plugin/.mcp.json`** — note the `${VARIABLE}` placeholders: + +```json +{ + "mcpServers": { + "token-validator": { + "url": "${MCP_SERVER_URL}/mcp", + "transport": "sse", + "headers": { + "Authorization": "Bearer ${MCP_SECRET_TOKEN}" + } + } + } +} +``` + +**`test-plugin/.plugin/plugin.json`** — minimal manifest: + +```json +{ + "name": "secret-token-validator", + "version": "1.0.0", + "description": "Plugin that connects to an MCP server using a per-conversation secret token", + "author": { "name": "OpenHands", "email": "openhands@all-hands.dev" }, + "license": "MIT" +} +``` + +**`test-plugin/SKILL.md`** — short markdown explaining the plugin's tools and +required secrets. The agent reads this so it knows what the plugin exposes. + +You point a conversation at this plugin by adding a `plugins` entry alongside +`secrets` in the start request: + +```python +"plugins": [{ + "source": "github:OpenHands/enterprise-cookbook", + "repo_path": "per-conversation-secrets/test-plugin" +}] +``` + +`source` resolves to a GitHub repo, `repo_path` is the directory within it. +OpenHands fetches the directory, reads `.mcp.json`, and **expands `${...}` +references against the `secrets` you passed in the same request** before +establishing the MCP transport. That expansion step is the whole point of +Pattern B. + +## APIs used + +These tests exercise **two separate OpenHands APIs**: + +### 1. App Server API + +- **Purpose:** Manages sandboxes, conversations, and user resources. +- **Base URL:** `https://app.all-hands.dev/api` (or your deployment). +- **Auth header:** `X-Access-Token: ` +- **OpenAPI spec:** `https://app.all-hands.dev/openapi.json` + +### 2. Agent Server API + +- **Purpose:** Direct agent interaction inside a running sandbox. +- **Base URL:** From the sandbox's `exposed_urls` array (entry with + `name="AGENT_SERVER"`). +- **Auth header:** `X-Session-API-Key: ` (from sandbox creation). +- **OpenAPI spec:** `{agent_server_url}/openapi.json` + +> **Tip:** To explore the Agent Server API, first create a sandbox via the App +> Server, wait for it to reach `RUNNING` status, then fetch +> `{agent_server_url}/openapi.json`. + +## Two ways to deliver the secrets + +Independent of A vs. B above, there are two *timings* for getting secrets into +the conversation: + +### 1. At conversation start (recommended) + +Pass secrets directly in the `POST /v1/app-conversations` request body: + +```python +requests.post( + f'{api_url}/v1/app-conversations', + headers={'X-Access-Token': api_key}, + json={ + 'sandbox_id': sandbox_id, + 'initial_message': {...}, + 'secrets': { + 'GITHUB_TOKEN': 'ghp_...', + 'MCP_SECRET_TOKEN': 'per-conv-secret-xyz-123', + 'MCP_SERVER_URL': 'https://...', + }, + 'plugins': [{ # only needed for Pattern B + 'source': 'github:OpenHands/enterprise-cookbook', + 'repo_path': 'per-conversation-secrets/test-plugin', + }], + }, +) +``` + +**Advantages:** + +- Single request — simpler API. +- Secrets available immediately when the agent starts. +- No race condition — guaranteed to be set before the agent runs. +- Secrets are merged with vault secrets (request secrets take precedence). +- **Required for Pattern B** — `${...}` expansion in a plugin's `.mcp.json` + needs the secrets to be present *before* the MCP transport is opened. + +**Requirements:** + +- OpenHands PR [#14009](https://github.com/OpenHands/OpenHands/pull/14009) +- SDK PR [#2873](https://github.com/OpenHands/software-agent-sdk/pull/2873) + +### 2. After conversation start (original) + +Inject secrets via the Agent Server's `/secrets` endpoint after the conversation +already exists: + +```python +requests.post( + f'{agent_server_url}/api/conversations/{conv_id}/secrets', + headers={'X-Session-API-Key': session_api_key}, + json={'secrets': {'MY_SECRET': 'value'}}, +) +``` + +**Use when:** + +- You need to add secrets mid-conversation. +- You're on an older OpenHands version without the at-start `secrets` field. + +Note: post-hoc injection works for Pattern A (bash env vars) but **does not help +with Pattern B**, because the MCP transport for a plugin is established when the +conversation starts. + +## Quick comparison + +| Feature | At start (new) | After start (original) | +| ------------------------ | ---------------------------- | -------------------------------------- | +| API | App Server | Agent Server | +| Endpoint | `POST /v1/app-conversations` | `POST /api/conversations/{id}/secrets` | +| Auth header | `X-Access-Token: {api_key}` | `X-Session-API-Key: {session_key}` | +| Timing | Before agent runs | After conversation created | +| Simplicity | Single request | Multiple requests | +| Mid-conversation updates | No | Yes | +| Works for Pattern A | Yes | Yes | +| Works for Pattern B | **Yes** | No | + +## Architecture + +```text +┌─────────────────────┐ ┌──────────────────────┐ ┌─────────────────────┐ +│ App Server │ │ Agent Server │ │ MCP Server │ +│ app.all-hands.dev │ │ (per-sandbox URL) │ │ (validates token) │ +├─────────────────────┤ ├──────────────────────┤ ├─────────────────────┤ +│ POST /v1/sandboxes │──▶│ │ │ │ +│ POST /v1/app-conv │ │ │ │ │ +│ • secrets │ │ Plugin loader │ │ │ +│ • plugins ────────┼──▶│ ┌────────────────┐ │ │ │ +│ │ │ │ fetch from GH │ │ │ │ +│ │ │ │ read .mcp.json │ │ │ │ +│ │ │ │ expand ${...} │ │ │ │ +│ │ │ │ ↑ from │ │ │ │ +│ │ │ │ secrets │ │ │ │ +│ │ │ └───────┬────────┘ │ │ │ +│ │ │ │ │ │ │ +│ │ │ ▼ │ │ │ +│ │ │ MCP transport ──────┼──▶│ validate_token() │ +│ │ │ (Authorization: │ │ │ +│ │ │ Bearer ) │ │ │ +│ │ │ │ │ │ +│ │ │ POST /secrets ──── (Pattern A, after-start) │ +│ │ │ POST /events │ │ │ +└─────────────────────┘ └──────────────────────┘ └─────────────────────┘ + │ │ │ + │ X-Access-Token: │ X-Session-API-Key: │ Authorization: + │ {api_key} │ {session_api_key} │ Bearer ${MCP_SECRET_TOKEN} +``` + +## Key findings + +1. **Two different conversation IDs.** The App Server and the Agent Server use + different IDs for the same conversation. You must query the Agent Server to + find the correct ID for its endpoints. +2. **Two authentication schemes.** + - App Server: `X-Access-Token: {api_key}` + - Agent Server: `X-Session-API-Key: {session_api_key}` +3. **Secrets endpoint (Agent Server):** `POST /api/conversations/{id}/secrets` + with body `{"secrets": {"KEY": "value"}}`. Secrets become environment + variables (`$KEY`) for bash commands. +4. **`${...}` expansion only works when secrets are passed at start.** A plugin + loaded via `plugins: [...]` has its `.mcp.json` expanded against the + `secrets` field of the same `POST /v1/app-conversations` request. Post-hoc + injection via the Agent Server's `/secrets` endpoint is too late to influence + MCP transport setup. + +## Files + +| File | Purpose | +| --------------------------------- | ---------------------------------------------------------------------------------------- | +| `test_secrets_at_start.py` | **Pattern A**, at-start: secrets as bash env vars, passed in the start request. | +| `test_secrets.py` | **Pattern A**, after-start: secrets injected via Agent Server `/secrets`. | +| `test_mcp_secrets_at_start.py` | **Pattern B**, at-start: secrets + plugin in one request, MCP config expanded from them. | +| `test_mcp_secrets.py` | **Pattern B**, after-start: legacy variant. Kept for reference. | +| `test-plugin/.mcp.json` | Plugin's MCP server registration with `${MCP_SERVER_URL}` / `${MCP_SECRET_TOKEN}`. | +| `test-plugin/.plugin/plugin.json` | Plugin manifest (name, version, author, license). | +| `test-plugin/SKILL.md` | Human-readable doc the agent reads to understand the plugin. | +| `mcp_server.py` | Stand-alone MCP server that validates the expected `Authorization: Bearer …` token. | + +## Usage + +### Pattern A — secrets at conversation start (recommended) + +```bash +export OH_API_KEY="sk-oh-..." + +# Optional: staging / feature deployment +# export OH_API_URL="https://ohpr-14009-30.staging.all-hands.dev/api" + +# Optional: reuse an existing RUNNING sandbox (avoids cold start) +# export OH_SANDBOX_ID="your-sandbox-id" + +python test_secrets_at_start.py +``` + +Expected output ends with: + +```text +====================================================================== + ✅ SUCCESS! Secrets field was accepted in AppConversationStartRequest! +====================================================================== +``` + +### Pattern A — secrets after conversation start (legacy) + +```bash +export OH_API_KEY="sk-oh-..." +python test_secrets.py +``` + +### Pattern B — secrets + plugin at conversation start (recommended) + +This test verifies that secrets passed at conversation start are available for +MCP config variable expansion inside the plugin's `.mcp.json`. + +```bash +# Terminal 1: start the MCP server +python mcp_server.py --port 12000 --expected-token "per-conv-secret-xyz-123" + +# Terminal 2: run the test +export OH_API_KEY="sk-oh-..." +export OH_API_URL="https://ohpr-14009-30.staging.all-hands.dev/api" # or your deployment +export MCP_SERVER_URL="https://work-1-xxx.prod-runtime.all-hands.dev" # where mcp_server.py is reachable + +python test_mcp_secrets_at_start.py +``` + +Expected output ends with: + +```text +====================================================================== + ✅ SUCCESS! Per-conversation secret was injected and used! +====================================================================== +``` + +What happened end-to-end: + +1. The test started a sandbox and called `POST /v1/app-conversations` with + `secrets={"MCP_SERVER_URL": ..., "MCP_SECRET_TOKEN": "per-conv-secret-xyz-123"}` + *and* `plugins=[{source: github:OpenHands/enterprise-cookbook, repo_path: per-conversation-secrets/test-plugin}]`. +2. OpenHands fetched `test-plugin/` from GitHub, read `.mcp.json`, and + substituted both `${MCP_SERVER_URL}` and `${MCP_SECRET_TOKEN}` from the + secrets above. +3. The agent dialed the resulting URL with header + `Authorization: Bearer per-conv-secret-xyz-123`. +4. `mcp_server.py` compared the token to its `--expected-token`, matched, and + returned a success result that the test then asserts on. + +## API workflow + +```python +# ============================================================ +# APP SERVER API (https://app.all-hands.dev/api) +# Auth: X-Access-Token header +# ============================================================ + +# 1. Create sandbox +POST /v1/sandboxes +Headers: X-Access-Token: {api_key} +→ {id, session_api_key, status: "STARTING", exposed_urls: null} + +# 2. Poll for RUNNING status +GET /v1/sandboxes/search +Headers: X-Access-Token: {api_key} +→ {items: [{id, status: "RUNNING", exposed_urls: [...], session_api_key}]} +# Find AGENT_SERVER in exposed_urls + +# 3. Start conversation WITH secrets (and optionally a plugin) +POST /v1/app-conversations +Headers: X-Access-Token: {api_key} +Body: { + sandbox_id: "...", + initial_message: {...}, + secrets: {...}, + plugins: [{source: "github:...", repo_path: "..."}] # for Pattern B +} + +# ============================================================ +# AGENT SERVER API (from exposed_urls AGENT_SERVER) +# Auth: X-Session-API-Key header +# ============================================================ + +# 4. Find conversation on agent server +GET /api/conversations/search +Headers: X-Session-API-Key: {session_api_key} +→ {items: [{id, status}]} + +# 5. Send message / check events +POST /api/conversations/{id}/events +GET /api/conversations/{id}/events/search +Headers: X-Session-API-Key: {session_api_key} +``` + +## API reference + +### App Server API + +| Endpoint | Method | Description | +| ----------------------- | ------ | ------------------------------------------------------------ | +| `/v1/sandboxes` | POST | Create sandbox → `{id, session_api_key, ...}` | +| `/v1/sandboxes/search` | GET | List sandboxes → `{items: [...]}` | +| `/v1/sandboxes/{id}` | DELETE | Delete sandbox (use `?sandbox_id=` query param) | +| `/v1/app-conversations` | POST | Start conversation (supports `secrets` and `plugins` fields) | + +### Agent Server API + +| Endpoint | Method | Description | +| --------------------------------------- | ------ | --------------------------------------- | +| `/api/conversations/search` | GET | List conversations → `{items: [...]}` | +| `/api/conversations/{id}/secrets` | POST | Inject secrets (Pattern A, after start) | +| `/api/conversations/{id}/events` | POST | Send user message | +| `/api/conversations/{id}/events/search` | GET | List events → `{items: [...]}` | + +### Getting the OpenAPI specs + +```bash +# App Server OpenAPI +curl https://app.all-hands.dev/openapi.json + +# Agent Server OpenAPI (requires a running sandbox) +# 1. Create sandbox and wait for RUNNING status +# 2. Get agent_server_url from exposed_urls (name="AGENT_SERVER") +curl {agent_server_url}/openapi.json +``` + +## Related + + + + Use OpenHands as a service account with per-user PATs + + + + Configure GPG signing with SessionStart hooks + + + + Full API documentation + + diff --git a/cookbook/service-account-github-pat.mdx b/cookbook/service-account-github-pat.mdx new file mode 100644 index 000000000..d4a0b94dd --- /dev/null +++ b/cookbook/service-account-github-pat.mdx @@ -0,0 +1,283 @@ +--- +title: Service Account GitHub PAT +description: Use one OpenHands account as a service account, overriding the managed GITHUB_TOKEN with each user's PAT per conversation. +icon: id-card +--- + +{/* GENERATED from OpenHands/enterprise-cookbook@126c20e4a3becac898c541aac120277a9c2835b3 (service-account-github-pat/README.md). Edit the source, not this file. */} + + + +**Question:** *Can I use one OpenHands SaaS account as a service account that performs +GitHub operations on behalf of many different users, by supplying each user's GitHub +Personal Access Token (PAT) as a per-conversation secret?* + +**Short answer:** **Yes.** OpenHands lets a conversation-start request override the +account's own managed `GITHUB_TOKEN` with a token you supply. So a single "backend" +account can front many users by passing the right user's PAT for each conversation. + +But "supply a PAT and you're done" hides three things that will bite you if you skip +them: + +1. **Where the token is (and isn't) readable** — the sandbox has two command paths and + only one of them sees the secret. +2. **The identity split** — the *push* credential and the *commit author* are two + different identities, and by default only the push side becomes the PAT owner. +3. **The clone trap** — if you attach a repository at conversation start, it is cloned + with the *service account's* credentials, not the PAT. + +This guide explains the mechanics and the techniques to get it right. + +*** + +## 1. The core mechanism: `secrets` overrides the managed token + +When you start a conversation you can pass a `secrets` map. `GITHUB_TOKEN` is explicitly +on the list of **overridable** system secrets, so a value you pass wins over the +account's own managed GitHub token: + +```jsonc +POST /api/v1/app-conversations +{ + "sandbox_id": "…", + "initial_message": { "role": "user", "content": [{ "type": "text", "text": "…" }] }, + "secrets": { + "GITHUB_TOKEN": "ghp_thePatOfTheUserYouAreActingFor" + } +} +``` + +What happens server-side: + +- The app-server first builds the secret set from the account's own identity: the + managed `GITHUB_TOKEN` is a **refreshing lookup** (a `LookupSecret` that calls back to + the app-server's `/api/v1/webhooks/secrets` to fetch a freshly-minted token each time + it's read). +- Your API-provided secrets are then **merged last, last-write-wins**. `GITHUB_TOKEN` + becomes a **static** value (your PAT), replacing the refreshing lookup. The server logs + `API-provided secret 'GITHUB_TOKEN' overrides existing secret`. + +Two consequences worth internalizing: + +- **The auto-refresh no longer applies to `GITHUB_TOKEN`.** That's fine for a PAT (PATs + don't need refreshing), but it means the token you inject is used as-is for the life of + the conversation. If it expires or is revoked mid-run, there is no refresh path. +- **There is no ownership check.** The platform does not verify that the PAT belongs to + the calling account or to any particular user. Whoever holds the service account's API + key can make the agent act with *any* PAT. Treat that API key accordingly (see + [§7 Security](#7-security-and-operational-considerations)). + +### What you may and may not override + +- `GITHUB_TOKEN`, `GITLAB_TOKEN`, `BITBUCKET_TOKEN`, `AZURE_DEVOPS_TOKEN`, + `FORGEJO_TOKEN`, and the AWS Bedrock credentials are **overridable** by design — this + BYO-credential flow is exactly what they're for. +- Names starting with `LLM_` are **blocked** (they enforce LLM controls). +- A set of container/infra names (`OH_WEBHOOKS_0_BASE_URL`, `OH_ALLOW_CORS_ORIGINS_0`, + worker ports, etc.) are **blocked** so you can't repoint callbacks or break the sandbox. +- Limits: a bounded number of secrets per request, bounded name length, bounded value + size. Names are validated. + +*** + +## 2. The two execution paths (this is the crux) + +Inside the sandbox there are **two different ways** a shell command can run, and they do +**not** have the same access to your secret: + +| | Agent tool-call path | Direct exec path | +| -------------------------- | ------------------------------------------------------ | -------------------------------------- | +| Who triggers it | the agent (LLM) running its `bash` tool | you, calling the agent-server REST API | +| Endpoint | conversation `events` → tool executor | `POST /api/bash/execute_bash_command` | +| Sees conversation secrets? | **Yes, but only if the command text names the secret** | **No — never** | + +Why: + +- Conversation secrets live in the SDK's **secret registry**. The registry is consulted + **only** by the agent's terminal tool, and it works by **scanning the command text** for + registered secret names. If the literal name (e.g. `GITHUB_TOKEN`) appears in the + command, its value is exported as an env var for that command; otherwise nothing is + injected. +- The direct `POST /api/bash/...` endpoint is a standalone bash service with no + conversation context and no secret registry. It runs the raw command. It cannot read + registry secrets no matter what the command says. + +Practical fallout: + +- `git remote set-url origin https://${GITHUB_TOKEN}@github.com/owner/repo.git` **works** + on the agent path — the literal `GITHUB_TOKEN` is present, so it's injected. +- `gh pr create …` on the agent path **does not** get the token — the string + `GITHUB_TOKEN` doesn't appear, so `gh` runs unauthenticated. Force it: + `GITHUB_TOKEN=$GITHUB_TOKEN gh pr create …`. +- Nested scripts, background processes, and git credential helpers only see the token if + the **top-level** command named it (env is then inherited by children). Once a command + in the agent's shell session references the secret, the `export` persists for the rest + of that session. + +> Rule of thumb: **on the agent path, name the secret in the command.** On the direct +> path, the registry secret isn't available at all — if you need a credential there, you +> must place it yourself (you hold the plaintext). + +*** + +## 3. The identity split: push credential ≠ commit author + +This is the detail most people miss. + +- **Push credential** = the token used for `git push`. Override `GITHUB_TOKEN` and this + becomes the **PAT owner**. ✅ +- **Commit author/committer** = the `user.name` / `user.email` baked into the commit at + `git commit` time. GitHub attributes a commit to an account by matching the **author + email**. The token has *no effect* on this. + +By default the app-server configures git identity **from the service account's** stored +`git_user_name` / `git_user_email` (as `git config --global`). So out of the box you get: + +- commits **authored by the service account** (or, if the account has no git identity + set, a container fallback like `root@…`), and +- pushed **as the PAT owner**. + +That split pollutes contribution graphs, "Verified"/linked authorship, and your audit +trail. **If attribution matters, set the committer identity to the target user yourself.** + +- Your system should **track each user's git identity** — specifically the GitHub-linked + email, typically the noreply form `ID+login@users.noreply.github.com` — and set it in + the sandbox: + ```bash + git config user.name "Jane Example" + git config user.email "12345+jane@users.noreply.github.com" + ``` + These are **non-secret strings**, so they work over either execution path. Repo-local + config overrides the account's `--global` default. +- Don't try to discover the identity from the token unless you must — `gh api user` needs + the token (so it must be on the agent path *and* name `GITHUB_TOKEN`), adds a round + trip, and adds a failure mode. Tracking it yourself is more robust. + +*** + +## 4. The clone trap: don't attach a repository at start + +When you pass `selected_repository` at conversation start, the app-server clones it +**before** your PAT is in play, using the **service account's** credentials — the +service-account token is embedded directly into the `origin` remote URL. You'd then have +a repo cloned and remote-authenticated as the service account while the agent thinks it's +the PAT user. Mixed identity, again. + +**Technique: start with no repository attached, and clone inside the session with the +PAT.** With no repo selected, the app-server skips the authenticated clone entirely, and +the agent's `GITHUB_TOKEN` is uniformly the injected PAT. + +The clone must be **agent-driven** (or done by you over the direct path with the plaintext +token), because in the plain app-conversation flow the PAT only reaches the sandbox once +the conversation starts — after the app-server's own setup step has already run. See the +sandbox-first flow below if you want to control that ordering. + +*** + +## 5. Two implementation flows + +### Flow A — Simple: app-conversation with `secrets` (no repo attached) + +Best when you're happy to let the agent do the cloning and you don't need setup to read +the token. + +1. `POST /api/v1/app-conversations` with `secrets: { GITHUB_TOKEN: }`, **no + `selected_repository`**, and an initial message that tells the agent to: + - set git identity to the target user (pass the name/email in the message or a skill), + - clone with the PAT, e.g. + `git clone https://x-access-token:${GITHUB_TOKEN}@github.com/owner/repo.git`, + - reference `GITHUB_TOKEN` explicitly in any `gh`/push commands. + +This is a superset of the [`per-conversation-secrets`](/cookbook/per-conversation-secrets) +example — same `secrets` field, just used for `GITHUB_TOKEN`. + +### Flow B — Sandbox-first: full control over ordering + +Best when you want the credential and identity **in place before the agent runs**, or you +want to pre-clone / run setup as the correct identity. + +1. `POST /api/v1/sandboxes` yourself; poll to `RUNNING`; read `session_api_key` and the + `AGENT_SERVER` URL from `exposed_urls`. +2. Drive the sandbox directly over `POST /api/bash/execute_bash_command` (you hold the + plaintext PAT, so you're not dependent on the registry here): + - `git config --global user.name/user.email` → the **target user's** identity, + - clone the repo with the PAT, or place a credential helper / env entry, + - run any setup you need. +3. Attach a conversation to the prepared sandbox: `POST /api/v1/app-conversations` with + that `sandbox_id`. **Also** register the PAT in the conversation `secrets` so the + agent's own tool-path commands can use `$GITHUB_TOKEN` by name. + +This builds on the [`start-sandbox`](/cookbook/start-sandbox) example (create sandbox → talk to +the agent-server directly), then attaches a conversation on top. + +> Important nuance: injecting secrets "at start" or via the agent-server's +> `POST /api/conversations/{id}/secrets` endpoint both land in the **conversation +> registry** — i.e. agent-tool-path only, name-scanned. Creating the sandbox earlier does +> **not** make those registry secrets readable by `POST /api/bash/...`. What the +> sandbox-first flow buys you is *ordering* plus the fact that *you already hold the +> plaintext* and can place it however you like. + +*** + +## 6. What still resolves to the service account (residual coupling) + +Even with no repo attached and the PAT injected, a few things are still the **service +account**, because they run at conversation start regardless: + +- **Skill loading.** The app-server enumerates the *service account's* login and orgs and + fetches their `.openhands` / `.agents` skill repos using the service account's token. + Those authenticated URLs (with the service-account token) are handed to the sandbox. If + you want zero service-account credential exposure in the sandbox, consider disabling + org/user skill loading for this flow, and keep the service account's scopes minimal. +- **Other providers.** Only `GITHUB_TOKEN` is overridden. Any `GITLAB_TOKEN`, + `BITBUCKET_TOKEN`, etc. still refresh against the service account. +- **Server-side attribution.** Conversation ownership, telemetry, and webhook identity are + the service account. GitHub-side actions are attributed to the PAT owner. Your audit + trail is inherently split across the two systems — plan for correlating them. + +*** + +## 7. Security and operational considerations + +- **The service account API key is now an "act as anyone" key.** There's no server-side + check tying an injected PAT to a user. Guard the API key like the high-value credential + it is; do your own authorization *before* choosing which PAT to inject. +- **Scope the PATs.** Prefer fine-grained PATs limited to the repos/permissions each + operation needs. The injected token becomes readable inside the sandbox (agent path) + and lives in conversation state for the conversation's lifetime. +- **Blast radius of the sandbox.** Anyone with the sandbox's `session_api_key` can drive + `POST /api/bash/...`. In the sandbox-first flow you place plaintext tokens there + yourself — treat the sandbox as sensitive and tear it down when done. +- **Output masking.** Secret values registered in the conversation are masked as + `` in tool output, but only for values the registry knows and has + resolved. Tokens you place yourself over the direct path are **not** masked — avoid + echoing them. +- **Prefer short-lived tokens where possible.** Because the override disables the managed + refresh, a long conversation with an expiring token has no recovery path; mint fresh or + keep conversations bounded. + +*** + +## 8. Implementation checklist + +- [ ] Authorize the request in **your** system and pick the correct user's PAT. +- [ ] Start the conversation **without** `selected_repository`. +- [ ] Pass the user's PAT as `secrets.GITHUB_TOKEN`. +- [ ] Set the **committer identity** to the target user (`git config user.name/email`, + using a GitHub-linked email you track). Do it before any commit. +- [ ] In agent commands, **name `GITHUB_TOKEN`** wherever the token is needed + (`GITHUB_TOKEN=$GITHUB_TOKEN gh …`, or embed it in the clone/remote URL). +- [ ] Decide clone strategy: agent-driven (Flow A) or pre-clone over the direct path + (Flow B). +- [ ] Minimize service-account scopes; consider disabling org/user skill loading. +- [ ] Have a correlation strategy for the split audit trail. +- [ ] Use fine-grained, minimally-scoped, ideally short-lived PATs; tear down sandboxes. + +*** + +## Related + +- [`per-conversation-secrets`](/cookbook/per-conversation-secrets) — the `secrets` field at + start vs. after start, and MCP `${VAR}` expansion. +- [`start-sandbox`](/cookbook/start-sandbox) — create a sandbox and drive its agent-server REST + API directly (the basis for Flow B). diff --git a/cookbook/start-sandbox.mdx b/cookbook/start-sandbox.mdx new file mode 100644 index 000000000..71ab963d7 --- /dev/null +++ b/cookbook/start-sandbox.mdx @@ -0,0 +1,136 @@ +--- +title: Start Sandbox +description: Start a sandbox without a conversation and run commands via the agent-server REST API. +icon: play +--- + +{/* GENERATED from OpenHands/enterprise-cookbook@126c20e4a3becac898c541aac120277a9c2835b3 (start-sandbox/README.md). Edit the source, not this file. */} + + + +A short script that creates an OpenHands Cloud sandbox via the V1 API, +waits for it to reach `RUNNING`, then talks directly to the sandbox's +agent-server REST API to run shell commands. + +No conversation is created — useful when you want a managed remote workspace +to drive yourself (e.g. for tooling, batch jobs, or programmatic agents). + +Two versions are provided: + +- [`sandbox_demo_brief.py`](https://github.com/OpenHands/enterprise-cookbook/blob/126c20e4a3becac898c541aac120277a9c2835b3/start-sandbox/sandbox_demo_brief.py) — \~25 lines, + one-numbered-step-per-block, minimal error handling. Best for quickly + understanding the API shape. +- [`sandbox_demo.py`](https://github.com/OpenHands/enterprise-cookbook/blob/126c20e4a3becac898c541aac120277a9c2835b3/start-sandbox/sandbox_demo.py) — same flow but split into a + `main()` function with docstrings and type hints, more in line with + the rest of the repo. + +Both do the same thing and both intentionally leave the sandbox running +at the end. + +> Want to go further? [`clone-and-attach`](/cookbook/clone-and-attach) builds on this +> example: it clones a repo and runs its `.openhands/setup.sh` in the sandbox, +> then **attaches a conversation** to the prepared sandbox. + +## APIs Used + +### 1. Cloud App Server — manages the sandbox lifecycle + +- Base URL: `https://app.all-hands.dev` +- Auth header: `X-Session-API-Key: ` +- Endpoints: + - `POST /api/v1/sandboxes` — start a sandbox (optional `?sandbox_spec_id=…`) + - `GET /api/v1/sandboxes?id=` — batch-get sandboxes by id + (returns `SandboxInfo` with `status`, `session_api_key`, `exposed_urls`, …) + +### 2. Agent Server — runs inside the sandbox + +- Base URL: the entry in `sandbox.exposed_urls` with `name == "AGENT_SERVER"`. + Inside the container the agent server listens on port **60000** (you'll see + that in the sample `ps -ef` output below and in the `port` field of the + `exposed_urls` entry); it is reverse-proxied to the public HTTPS URL, so you + always talk to it over `https://…` (port 443) — never the internal port. +- Auth header: `X-Session-API-Key: ` returned by the + sandbox-create call (different from your Cloud API key) +- All routes are under `/api/...`. This example uses: + - `POST /api/bash/execute_bash_command` — run a bash command and wait + for its result; body `{ "command": "...", "timeout": 30, "cwd": "..." }`, + response includes `stdout`, `stderr`, `exit_code` + +> Full Agent Server schema is available at +> `/openapi.json` once the sandbox is `RUNNING`. + +## What it prints + +```text +sandbox: 59Ji2kvkUtZZSm7zAkAxwN + status: RUNNING +agent: https://rzwfxneubhwcfpav.prod-runtime.all-hands.dev + +=== ls -la /workspace === +drwxr-sr-x bash_events +drwxr-sr-x conversations +drwxrws--- lost+found + +=== agent-server process === +openhan+ 1 /usr/local/bin/openhands-agent-server --port 60000 +openhan+ 38 /usr/local/bin/openhands-agent-server --port 60000 +``` + +`/workspace` is the sandbox's working tree. The agent-server is the binary +target built from the +[software-agent-sdk Dockerfile](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-agent-server/openhands/agent_server/docker/Dockerfile). +You'll see two processes: a uvicorn parent and a worker. + +## Run it + +```bash +export OH_API_KEY=... # your https://app.all-hands.dev API key +pip install requests +python sandbox_demo_brief.py # or sandbox_demo.py +``` + +`sandbox_demo.py` also takes flags / env vars so you can reuse it as-is: + +| Flag | Env var | Default | +| ------------------- | ----------------- | ---------------------------- | +| `--api-key` | `OH_API_KEY` | — (required) | +| `--base-url` | `OH_API_BASE` | `https://app.all-hands.dev` | +| `--sandbox-spec-id` | `SANDBOX_SPEC_ID` | none (account default image) | +| `--poll-timeout` | `POLL_TIMEOUT` | `180` (seconds) | + +Neither script deletes the sandbox at the end so you can poke at it. +Clean up with the `DELETE` endpoint — note the sandbox id goes in **both** the +path and a required `sandbox_id` query parameter: + +```bash +SID= +curl -X DELETE "https://app.all-hands.dev/api/v1/sandboxes/${SID}?sandbox_id=${SID}" \ + -H "X-Session-API-Key: $OH_API_KEY" +``` + +## Notes + +- A freshly created sandbox starts in `STARTING`; `session_api_key` and + `exposed_urls` are `null` until it becomes `RUNNING`. Polling every few + seconds is sufficient. +- The Cloud API has no single-sandbox `GET /sandboxes/{id}` — use the + batch-get endpoint `GET /sandboxes?id=` and read the first item. +- To pick a specific runtime image, pass `?sandbox_spec_id=` to the + `POST /api/v1/sandboxes` call. List available specs with + `GET /api/v1/sandbox-specs/search`. + +## Related + + + + Clone a repo and attach a conversation + + + + Delete conversations and release PVCs + + + + Full API documentation + + diff --git a/docs.json b/docs.json index 8572c832f..43a3769cd 100644 --- a/docs.json +++ b/docs.json @@ -647,12 +647,28 @@ "cookbook/index" ] }, + { + "group": "Sandbox lifecycle", + "pages": [ + "cookbook/archive-sandbox", + "cookbook/clone-and-attach", + "cookbook/start-sandbox" + ] + }, { "group": "Conversation monitoring & reacting", "pages": [ "cookbook/conversation-tags" ] }, + { + "group": "Secrets & authentication", + "pages": [ + "cookbook/gpg-commit-signing", + "cookbook/per-conversation-secrets", + "cookbook/service-account-github-pat" + ] + }, { "group": "Guardrails", "pages": [