diff --git a/cookbook/archive-sandbox.mdx b/cookbook/archive-sandbox.mdx new file mode 100644 index 000000000..88f5a8618 --- /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@main (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..6ccbd1327 --- /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@main (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..e2a656a51 100644 --- a/cookbook/command-blacklist.mdx +++ b/cookbook/command-blacklist.mdx @@ -1,5 +1,5 @@ --- -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 --- @@ -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`](/cookbook/load-plugin) example: ```bash cd ../load-plugin @@ -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`](/cookbook/command-whitelist) example that shows the **whitelist** approach (only allow explicitly approved commands). ## Hook Types @@ -190,30 +190,6 @@ This follows the **Claude Code plugin format**, compatible with: - Claude Desktop plugin marketplace - Any system supporting the `.claude-plugin` spec -## Related - - - - Full hook documentation - - - - How plugins work - - - - Programmatic plugin loading - - - - No-code plugin launcher - - - - Whitelist approach (opposite strategy) - - - ## Real-World Use Cases - **Onboarding agents** - Prevent trainees from dangerous operations @@ -240,3 +216,27 @@ fi ``` The inline bash makes it easy to iterate without rebuilding images or restarting servers. + +## Related + + + + Full hook documentation + + + + How plugins work + + + + Programmatic plugin loading + + + + No-code plugin launcher + + + + Whitelist approach (opposite strategy) + + diff --git a/cookbook/command-whitelist.mdx b/cookbook/command-whitelist.mdx new file mode 100644 index 000000000..71ee92c18 --- /dev/null +++ b/cookbook/command-whitelist.mdx @@ -0,0 +1,242 @@ +--- +title: Command Whitelist +description: Allow only approved shell commands with PreToolUse hooks (whitelist approach for strict security). +icon: user-shield +--- + +{/* GENERATED from OpenHands/enterprise-cookbook@main (command-whitelist/README.md). Edit the source, not this file. */} + + + +A self-contained example showing how to use **PreToolUse hooks** in a plugin to **whitelist approved shell commands**. The agent can only execute commands that are explicitly on the approved list - everything else is blocked. + +This example demonstrates the **whitelist approach**: deny everything by default, only allow specific approved commands. + +## What's in the Box + +The [`strict-mode/`](https://github.com/OpenHands/enterprise-cookbook/tree/main/command-whitelist/strict-mode) plugin bundles: + +- **Hooks** (`hooks/hooks.json`) - PreToolUse hook that validates commands against a whitelist +- **Skill** (`skills/strict-mode/SKILL.md`) - Documentation about what's allowed +- **Plugin manifest** (`.claude-plugin/plugin.json`) - Standard Claude Code plugin format + +## How It Works + +```mermaid +flowchart TD + A["User: #quot;Install the requests package with pip#quot;"] --> B["Agent: *prepares terminal command: pip install requests*"] + B --> C["PreToolUse Hook: *intercepts before execution*"] + C --> C1["Extracts command name: #quot;pip#quot;"] + C1 --> C2["Checks whitelist: [ls, cat, grep, find, ...]"] + C2 --> C3["Not found in whitelist!"] + C3 --> C4["Returns exit code 2 (block) + explanation"] + C4 --> D["Agent: *receives block + reason, explains to user*"] + D --> E["User: *sees which commands are allowed*"] +``` + +## Whitelisted Commands + +Only these commands are allowed (all read-only operations): + +**File Operations:** `ls`, `cat`, `head`, `tail`, `file` +**Search & Filter:** `grep`, `find`, `wc` +**System Info:** `pwd`, `whoami`, `date`, `uname`, `df`, `du`, `stat` +**Utilities:** `echo`, `which`, `env`, `printenv`, `history`, `tree` + +Everything else is **blocked by default**. + +## Run It + + + + Use the companion [`load-plugin`](/cookbook/load-plugin) example: + + ```bash + cd ../load-plugin + + # This will be blocked (pip not whitelisted) + python load_plugin.py \ + --repo-path command-whitelist/strict-mode \ + --message "Install the requests package" + + # This will be allowed (ls is whitelisted) + python load_plugin.py \ + --repo-path command-whitelist/strict-mode \ + --message "List all Python files in the current directory" + ``` + + + + Click to test strict mode: + + [![Try Strict Mode](https://img.shields.io/badge/Try%20Strict%20Mode-blue)](https://app.all-hands.dev/launch?plugins=W3sic291cmNlIjogImdpdGh1YjpPcGVuSGFuZHMvZW50ZXJwcmlzZS1jb29rYm9vayIsICJyZWYiOiAibWFpbiIsICJyZXBvX3BhdGgiOiAiY29tbWFuZC13aGl0ZWxpc3Qvc3RyaWN0LW1vZGUifV0%3D\&message=Install%20the%20requests%20package) + + + + + To test the plugin from a branch before it's merged, pass `--ref ` to `load_plugin.py`. + + +## The Hook + +The magic happens in [`hooks/hooks.json`](https://github.com/OpenHands/enterprise-cookbook/blob/main/command-whitelist/strict-mode/hooks/hooks.json): + +```json +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "terminal", + "hooks": [ + { + "type": "command", + "command": "input=$(cat)\n# ... POSIX-sh whitelist check ...\nexit 0", + "timeout": 5 + } + ] + } + ] + } +} +``` + +**How it works:** + +1. **`PreToolUse`** - Runs **before** the terminal tool executes +2. **`matcher: "terminal"`** - Only applies to shell commands +3. **Inline POSIX-sh script** (run via `/bin/sh -c`; kept inline rather than a + `bash -c '...'` wrapper or an external `.sh` file — see the note in the + [command-blacklist README](/cookbook/command-blacklist#the-hook) for why): + - Extracts the command name from the JSON input + - Checks if it's in the hardcoded whitelist + - Returns `exit 0` (allow) or `exit 2` (block, with a `{"decision":"deny",...}` reason) + +The whitelist is maintained as a simple space-separated list in the script: + +```sh +allowed="ls cat grep find head tail wc echo pwd whoami date uname df du tree file which env printenv history stat" +``` + +## Whitelist vs. Blacklist + +| Approach | Strategy | Security | Usability | Best For | +| ------------------------------------------------------------------ | -------------------------------- | ----------------------------------------------- | -------------------------------------------- | ------------------------------------- | +| **Whitelist** (this) | Deny by default, allow specific | ✅ **High** - Can't execute unexpected commands | ⚠️ **Limited** - Must pre-approve everything | High-security, read-only, educational | +| **Blacklist** ([`command-blacklist`](/cookbook/command-blacklist)) | Allow by default, block specific | ⚠️ **Medium** - New patterns might slip through | ✅ **Full** - Everything works except blocked | General protection, development work | + +**Whitelist** = "Only these few things are allowed" +**Blacklist** = "Everything is allowed except these specific things" + +## When to Use Each Approach + +### Use Whitelist (Strict Mode) When: + +- 🎓 **Educational** - Teaching safe command usage +- 🔍 **Analysis only** - Reading/inspecting systems +- 🛡️ **Maximum security** - Untrusted users or agents +- 📊 **Auditing** - Examining existing systems +- 🧪 **Sandboxes** - Limiting experimental environments + +### Use Blacklist (Safety Guardian) When: + +- 🚀 **Development** - Need full tooling access +- 🔧 **General protection** - Block obvious dangers +- ⚡ **Productivity** - Don't want to pre-approve everything +- 🏗️ **Building** - Need to install, compile, deploy +- 🎯 **Specific risks** - Known dangerous patterns to block + +## Extending the Whitelist + +To allow additional commands, edit the `allowed` list in `hooks/hooks.json` +(add the command name, space-separated): + +```sh +allowed="ls cat grep find ... git python npm" +``` + +Each command name is matched exactly - no wildcards or partial matches. For commands with subcommands (like `git clone`), you'll need to whitelist the main command (`git`) and handle subcommand validation separately if needed. + +## Security Considerations + +**✅ Strengths:** + +- Completely locks down command execution +- Easy to audit (small whitelist) +- Can't be bypassed by clever command variations +- Works well for truly untrusted agents + +**⚠️ Limitations:** + +- Very restrictive (might frustrate users) +- Requires updating the list as needs evolve +- Doesn't prevent reading sensitive files (allows `cat /etc/passwd`) +- Simple command extraction (not full shell parsing) + +For production security, consider: + +1. Combining with file path restrictions +2. Adding argument validation (not just command name) +3. Logging all blocked attempts +4. Using a proper JSON parser instead of grep + +## Plugin Structure + +```text +strict-mode/ +├── .claude-plugin/ +│ └── plugin.json # Plugin metadata +├── hooks/ +│ └── hooks.json # PreToolUse hook definition +└── skills/ + └── strict-mode/ + └── SKILL.md # Documentation (auto-loaded) +``` + +This follows the **Claude Code plugin format**, compatible with: + +- OpenHands Cloud plugin launcher +- Claude Desktop plugin marketplace +- Any system supporting the `.claude-plugin` spec + +## Real-World Use Cases + +- **Code review agents** - Only allow read operations on source code +- **Security auditing** - Inspect systems without modification +- **Student environments** - Safe learning sandbox +- **Public demos** - Allow exploration without damage +- **CI/CD read-only steps** - Verify without changing artifacts + +## Progressive Enhancement + +Start strict, then gradually expand: + +1. **Day 1:** Only allow `ls`, `cat`, `grep` (ultra-strict) +2. **Week 1:** Add `find`, `wc`, `head`, `tail` (more inspection tools) +3. **Month 1:** Add `git` for version control (read-only) +4. **As needed:** Carefully evaluate and add new commands + +This way you build trust and understand usage patterns before opening up. + +## Related + + + + Full hook documentation + + + + How plugins work + + + + Programmatic plugin loading + + + + No-code plugin launcher + + + + Blacklist approach (opposite strategy) + + diff --git a/cookbook/conversation-metrics.mdx b/cookbook/conversation-metrics.mdx new file mode 100644 index 000000000..dea555ef0 --- /dev/null +++ b/cookbook/conversation-metrics.mdx @@ -0,0 +1,272 @@ +--- +title: Conversation Metrics +description: Retrieve cost and token usage metrics for conversations via CLI tool. +icon: chart-line +--- + +{/* GENERATED from OpenHands/enterprise-cookbook@main (conversation-metrics/README.md). Edit the source, not this file. */} + + + +CLI tool to retrieve cost and token usage metrics for OpenHands conversations. + +Supports both **V0** and **V1** APIs, automatically selecting the appropriate one based on the conversation version. + +## Features + +- **Auto-detects** conversation version (V0 vs V1) and uses the appropriate API +- **Graceful fallback** chain for metric retrieval +- **Displays** cost (USD), token counts, cache stats, and context window +- **JSON output** for programmatic use +- **API call logging** for debugging and development +- **Fixture-based testing** - high coverage via recorded API responses +- **Zero dependencies** - uses only Python standard library + +## How It Works + +The tool uses a fallback chain to find metrics: + +1. **Check conversation version** via `/api/conversations/{id}` +2. **For V1 conversations**: + - First try `/api/v1/app-conversations?ids={id}` which includes a `metrics` object + - If metrics are all zeros, fall back to `/api/v1/conversation/{id}/events/search` and extract metrics from `ConversationStateUpdateEvent` at `value.stats.usage_to_metrics.agent` +3. **For V0 conversations** (or if V1 fails): Use `/api/conversations/{id}/events` and find the latest event with `llm_metrics` +4. **Last resort**: Use `/api/conversations/{id}/trajectory` and scan for `llm_metrics` + + + **Note:** Some V1 conversations have metrics stored only in events (not in the app-conversations response). The fallback chain ensures these are still retrieved correctly. + + +## Installation + +No installation required - just make the script executable: + +```bash +chmod +x oh-metrics +``` + +Requires Python 3.10+. + +## Run It + +### Set your API key + +```bash +export OH_API_KEY="your-api-key" +``` + +### Get metrics for a conversation + +```bash +./oh-metrics +``` + +**Example output (V0 conversation):** + +```text +──────────────────────────────────────────────────────────── +Conversation: 7f3d57c4e5b2434d9ca78e5e27311137 +Title: Hello and a Programming Joke +API Version: V0 via V0 (events) +──────────────────────────────────────────────────────────── +💰 Total Cost: $0.083175 USD + +📊 Token Usage: + Prompt tokens: 12,865 + Completion tokens: 117 + Total tokens: 12,982 + +🗄️ Cache: + Cache read: 0 + Cache write: 12,740 + +📐 Context window: 200,000 +──────────────────────────────────────────────────────────── +``` + +**Example output (V1 conversation):** + +```text +──────────────────────────────────────────────────────────── +Conversation: 72d40619b8534f9b9de6c3f17a71072d +Title: 📝 V0 API for Conversation Costs & Tokens +API Version: V1 via V1 (events) +──────────────────────────────────────────────────────────── +💰 Total Cost: $13.576675 USD + +📊 Token Usage: + Prompt tokens: 17,577,146 + Completion tokens: 63,985 + Total tokens: 17,641,131 + +🗄️ Cache: + Cache read: 17,022,477 + Cache write: 553,973 + +🧠 Reasoning tokens: 1,169 + +📐 Context window: 200,000 +──────────────────────────────────────────────────────────── +``` + +### JSON output + +```bash +./oh-metrics --json +``` + +```json +{ + "conversation_id": "7f3d57c4e5b2434d9ca78e5e27311137", + "title": "Hello and a Programming Joke", + "api_version": "V0", + "api_used": "V0 (events)", + "metrics": { + "accumulated_cost": 0.083175, + "accumulated_token_usage": { + "prompt_tokens": 12865, + "completion_tokens": 117, + "cache_read_tokens": 0, + "cache_write_tokens": 12740, + "context_window": 200000 + } + } +} +``` + +### Options + +| Option | Short | Description | +| ----------------- | ----- | ------------------------------------------------- | +| `--api-key KEY` | `-k` | API key (defaults to `OH_API_KEY` env var) | +| `--base-url URL` | `-u` | Base URL (default: `https://app.all-hands.dev`) | +| `--json` | `-j` | Output in JSON format | +| `--log-api-calls` | `-l` | Log all API requests/responses to `.oh/api-logs/` | +| `--help` | `-h` | Show help message | + +### Logging API Calls + +For debugging or development, you can log all API requests and responses: + +```bash +./oh-metrics --log-api-calls +``` + +This creates timestamped files in `.oh/api-logs/YYYYMMDD-HHMMSS/`: + +```text +.oh/api-logs/20260227-145230/ +├── 0001-request.json +├── 0001-response.json +├── 0002-request.json +└── 0002-response.json +``` + +Each request/response pair is numbered sequentially. The Authorization header is redacted from logged requests. + +## Metrics Explained + +| Field | Description | +| -------------------- | ----------------------------------------- | +| `accumulated_cost` | Total cost in USD | +| `prompt_tokens` | Tokens sent to the LLM | +| `completion_tokens` | Tokens generated by the LLM | +| `cache_read_tokens` | Tokens read from prompt cache | +| `cache_write_tokens` | Tokens written to prompt cache | +| `reasoning_tokens` | Tokens used for reasoning (if applicable) | +| `context_window` | Model's context window size | + +## Architecture + +The library is organized into separate modules: + +```text +oh_api/ +├── __init__.py # Public API exports +├── client.py # Base HTTP client with logging and fixture support +├── v0.py # V0 API driver +├── v1.py # V1 API driver +└── metrics.py # High-level metrics retrieval with fallback chain +``` + +### Using the Library Programmatically + +```python +from oh_api import APIClient, get_conversation_metrics + +# Create a client +client = APIClient( + base_url="https://app.all-hands.dev", + api_key="your-api-key", + log_api_calls=True, # Optional: enable logging +) + +# Get metrics +metrics = get_conversation_metrics(client, "conversation-id") +if metrics: + print(f"Cost: ${metrics.accumulated_cost:.6f}") + print(f"Tokens: {metrics.total_tokens:,}") +``` + +### Using the V0/V1 Drivers Directly + +```python +from oh_api import APIClient +from oh_api.v0 import V0Driver +from oh_api.v1 import V1Driver + +client = APIClient(base_url="...", api_key="...") + +# V0 API +v0 = V0Driver(client) +conv = v0.get_conversation("conv-id") +events = v0.get_events("conv-id", limit=50) + +# V1 API +v1 = V1Driver(client) +conv = v1.get_conversation("conv-id") +if conv and conv.metrics: + print(f"Cost: ${conv.metrics.accumulated_cost}") +``` + +## Testing + +The test suite uses recorded API fixtures to achieve high coverage without making real API calls: + +```bash +# Run tests +uv run pytest tests/ -v + +# Run with coverage +uv run pytest tests/ --cov --cov-report=term-missing +``` + +Current coverage: **89%** of library code. + +### Creating New Fixtures + +1. Run the CLI with `--log-api-calls` to capture real API responses +2. Copy relevant response files to `tests/fixtures/` +3. Rename following the pattern: `GET__api_path_q_param=value.json` + +## APIs Used + +### For V1 Conversations + +| Endpoint | Purpose | +| --------------------------------------------- | -------------------------------------------------------- | +| `GET /api/conversations/{id}` | Get conversation info (to determine version) | +| `GET /api/v1/app-conversations?ids={id}` | Get conversation with metrics | +| `GET /api/v1/conversation/{id}/events/search` | Fallback: find metrics in `ConversationStateUpdateEvent` | + +### For V0 Conversations + +| Endpoint | Purpose | +| ---------------------------------------- | ----------------------------- | +| `GET /api/conversations/{id}` | Get conversation info | +| `GET /api/conversations/{id}/events` | Get events with `llm_metrics` | +| `GET /api/conversations/{id}/trajectory` | Fallback for metrics | + +## License + +MIT License - see [LICENSE](https://github.com/OpenHands/enterprise-cookbook/blob/main/LICENSE) for details. diff --git a/cookbook/conversation-tags.mdx b/cookbook/conversation-tags.mdx index c6868c431..43e71cd11 100644 --- a/cookbook/conversation-tags.mdx +++ b/cookbook/conversation-tags.mdx @@ -1,5 +1,5 @@ --- -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 --- @@ -16,7 +16,7 @@ exactly this. This is the supported replacement for adding a bespoke field (e.g. a custom `environment_url` column) to the conversation model: use `tags` instead. -## The two-server split +## How It Works OpenHands has a **Cloud app server** (manages accounts, sandboxes, and conversations) and, for each sandbox, an **agent server** (the runtime that owns @@ -44,14 +44,16 @@ immediately (`GET {conversation_url}` → `tags`). The Cloud's up within a few seconds — so this example confirms the write on the agent server and then *polls* the Cloud read instead of reading once. -> Why not set tags on the Cloud create call? The Cloud -> `POST/PATCH /api/v1/app-conversations` payloads do not expose `tags` today — -> 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)). + + Why not set tags on the Cloud create call? The Cloud + `POST/PATCH /api/v1/app-conversations` payloads do not expose `tags` today — + 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`](/cookbook/clone-and-attach)). + -## Tag rules +## Tag Rules The agent server enforces: @@ -64,7 +66,7 @@ The agent server enforces: Need to store something structured or longer than 256 chars? Put a JSON string into a single tag value (within the limit), or split across multiple keys. -## Run it +## Run It ```bash export OH_API_KEY=... # your https://app.all-hands.dev API key @@ -100,7 +102,7 @@ round-trip OK: True deleted sandbox 3NjFZz5JDyIVUdvxNsXi0R ``` -## Set your own tags +## Set Your Own Tags Pass `--tag KEY=VALUE` (repeatable), and `--keep` to leave the conversation open so you can inspect the tags in the UI: @@ -122,7 +124,7 @@ python tag_conversation.py \ | `--keep` | — | off | Don't delete the conversation/sandbox | | `--poll-timeout` | `POLL_TIMEOUT` | `240` | Seconds to wait for readiness | -## API endpoints used +## APIs Used | Endpoint | Server | Purpose | | ------------------------------------------------ | ------ | ---------------------------------------------------------- | diff --git a/cookbook/custom-agent-no-browser.mdx b/cookbook/custom-agent-no-browser.mdx new file mode 100644 index 000000000..004825979 --- /dev/null +++ b/cookbook/custom-agent-no-browser.mdx @@ -0,0 +1,358 @@ +--- +title: Custom Agent No Browser +description: Configure agent tools via the agent-server API to exclude the browser tool. +icon: sliders +--- + +{/* GENERATED from OpenHands/enterprise-cookbook@main (custom-agent-no-browser/README.md). Edit the source, not this file. */} + + + +This example demonstrates **the correct pattern** for customizing agent tools using the OpenHands agent-server API. It configures an agent that has `terminal`, `file_editor`, and `task_tracker` but **no browser tool**, then verifies the result. + +## What This Example Shows + +- ✅ How to configure which tools the agent can access, (e.g. use standard tools, but exclude browser tools) +- ✅ How to verify that the intended tools are present and excluded ones are absent +- ✅ The complete workflow from sandbox creation to cleanup + +## The Key Insight: Two APIs + +OpenHands Cloud exposes **two different APIs**, and tool configuration happens on the second one: + +1. **Cloud API** (`https://app.all-hands.dev`) — manages the sandbox lifecycle (create, list, delete). Authenticated with your Cloud API key (`Authorization: Bearer $OH_API_KEY`). +2. **Agent-server API** — runs *inside* each sandbox at the sandbox's own URL. It controls agent configuration (LLM, tools) and conversation execution. Authenticated with the sandbox's **session API key** (`X-Session-API-Key: {session_key}`). + +To customize tools you must talk to the **agent-server API**. The reliable way to do it is to **pass the tool list inline** in the `agent` object when you create the conversation: + +```python +# session_key and agent_server_url come from the sandbox (see "How It Works"). +requests.post( + f"{agent_server_url}/api/conversations", + headers={"X-Session-API-Key": session_key}, + json={ + "agent": { + "llm": { + "model": "litellm_proxy/claude-sonnet-4-5-20250929", + "api_key": os.getenv("LLM_API_KEY"), + "base_url": os.getenv("LLM_BASE_URL"), + }, + "tools": [ + {"name": "terminal"}, + {"name": "file_editor"}, + {"name": "task_tracker"}, + ], + }, + "workspace": {"working_dir": "/workspace"}, + "initial_message": {"content": [{"text": "Hello!"}]}, + }, +) +``` + +Because the `agent` object defines the **whole** agent spec (LLM **and** tools) in a single request, the tools always take effect. The agent-server automatically adds the `finish` and `think` tools, so the resulting conversation exposes those two plus the three you asked for. The browser is excluded simply by not being in the list. + + + **Pitfall to avoid:** Do not set tools separately via `PATCH /api/settings` and then create the conversation with an `agent` object that contains only an `llm`. Sending an `agent` object without `tools` replaces the whole agent spec and drops the tools you configured, leaving the agent with just `finish` and `think`. Passing tools inline (as above) avoids this. + + +## How It Works + +### 1. Create a Sandbox via the Cloud API + +```python +response = requests.post( + "https://app.all-hands.dev/api/v1/sandboxes", + headers={"Authorization": f"Bearer {OH_API_KEY}"}, +) +sandbox = response.json() +sandbox_id = sandbox["id"] +``` + +Then poll `GET /api/v1/sandboxes?id={sandbox_id}` until `status == "RUNNING"`. +A running sandbox gives you: + +- `id`: the sandbox identifier +- `session_api_key`: authentication for the agent-server API +- `exposed_urls`: a list of `{name, url, port}`; the agent-server URL is the + entry whose `name` is `AGENT_SERVER` + +```python +session_key = sandbox["session_api_key"] +agent_server_url = next( + u["url"] for u in sandbox["exposed_urls"] if u["name"] == "AGENT_SERVER" +) +``` + +### 2. Create the Conversation with Tools Inline + +```python +requests.post( + f"{agent_server_url}/api/conversations", + headers={"X-Session-API-Key": session_key}, + json={ + "agent": { + "llm": {"model": ..., "api_key": ..., "base_url": ...}, + "tools": [ + {"name": "terminal"}, + {"name": "file_editor"}, + {"name": "task_tracker"}, + ], + }, + "workspace": {"working_dir": "/workspace"}, + "initial_message": {"content": [{"text": TASK}]}, + }, +) +``` + +### 3. Run the Conversation + +```python +requests.post( + f"{agent_server_url}/api/conversations/{conv_id}/run", + headers={"X-Session-API-Key": session_key}, +) +``` + +Then poll `GET /api/conversations/{conv_id}` until `execution_status` is +`finished` (or `error`). + +### 4. Verify Tools + +The script performs two checks: + +**a) Available tools (from the `SystemPromptEvent`):** confirms every expected +tool is present and no browser tool appears. + +```python +response = requests.get( + f"{agent_server_url}/api/conversations/{conv_id}/events/search" + "?kind__eq=SystemPromptEvent&limit=1", + headers={"X-Session-API-Key": session_key}, +) +available_tools = response.json()["items"][0]["tools"] +available_titles = {t["title"] for t in available_tools} + +missing = [name for name in ["terminal", "file_editor", "task_tracker"] + if name not in available_titles] +has_browser = any("browser" in t.get("title", "").lower() for t in available_tools) +assert not missing and not has_browser +``` + +**b) Tools actually used (from `ActionEvent`s):** confirms no browser tool was +invoked while performing the task. + +```python +response = requests.get( + f"{agent_server_url}/api/conversations/{conv_id}/events/search", + headers={"X-Session-API-Key": session_key}, +) +tools_used = { + event["tool_name"] + for event in response.json()["items"] + if event.get("kind") == "ActionEvent" +} +``` + +### 5. Cleanup + +Delete the conversation, then the sandbox. + +```python +requests.delete( + f"{agent_server_url}/api/conversations/{conv_id}", + headers={"X-Session-API-Key": session_key}, +) + +# DELETE /api/v1/sandboxes/{id} requires sandbox_id as BOTH a path segment AND a +# query parameter; omitting the query parameter returns HTTP 422 and leaks the +# sandbox. +requests.delete( + f"https://app.all-hands.dev/api/v1/sandboxes/{sandbox_id}", + params={"sandbox_id": sandbox_id}, + headers={"Authorization": f"Bearer {OH_API_KEY}"}, +) +``` + +## Run It + +### Prerequisites + +```bash +pip install requests + +# OpenHands Cloud API key (for sandbox management) +export OH_API_KEY=your-cloud-api-key + +# OpenHands LLM API key (get from Profile -> API Keys) +export LLM_API_KEY=your-llm-api-key + +# LiteLLM proxy URL (default works for OpenHands Cloud) +export LLM_BASE_URL=https://llm-proxy.app.all-hands.dev/ +``` + + + **Important:** When using the agent-server API directly, you must provide both + `LLM_API_KEY` and `LLM_BASE_URL`. The agent-server needs to know where to send + LLM requests and how to authenticate with the LiteLLM proxy. + + +### Run + +```bash +python agent_no_browser.py +``` + +The script creates a sandbox, creates a conversation with the custom tools, +runs a small task, verifies the tools, and deletes the sandbox. It exits with a +non-zero status if verification fails. + +Expected output: + +```text +=== Creating sandbox via Cloud API === + sandbox: 2osAXsenK3xynchCyUvt4T + waiting for sandbox... + status: RUNNING + ✓ sandbox ready: 2osAXsenK3xynchCyUvt4T + agent-server: https://xxxx.prod-runtime.all-hands.dev + +=== Creating conversation === + model: litellm_proxy/claude-sonnet-4-5-20250929 + base_url: https://llm-proxy.app.all-hands.dev/ + tools: terminal, file_editor, task_tracker + ✓ conversation created: 392e2589-... + +=== Running conversation === + conversation already running + waiting for completion... + execution_status: running + ✓ conversation completed successfully + +=== Verifying tools === + + Available tools: + total tools: 5 + + 🔧 Core tools (5): + • terminal + • file_editor + • task_tracker + • finish + • think + + ✅ PASS: all expected tools present: ['terminal', 'file_editor', 'task_tracker'] + ✅ PASS: no browser tools in available tools list + + Tools actually used: ['file_editor'] + ✅ PASS: no browser tools were used + +=== Results === +View conversation: https://app.all-hands.dev/conversations/392e2589-... +Agent-server: https://xxxx.prod-runtime.all-hands.dev + +=== Cleanup === + ✓ deleted conversation 392e2589-... + ✓ deleted sandbox 2osAXsenK3xynchCyUvt4T + +✅ Success: agent configured with the expected tools (no browser). +``` + + + The exact tool set can vary with your account configuration (for example, MCP + integrations may add more tools). What this example guarantees is that the + three requested tools are present and no browser tool is included. + + +### Keep Resources for Inspection + +```bash +python agent_no_browser.py --keep +``` + +This skips cleanup so you can inspect the conversation in the UI. + +## Available Tools + +Common tool names you can include: + +- `terminal` - Execute bash commands +- `file_editor` - Read/write/edit files +- `task_tracker` - Track tasks and progress +- `browser_tool_set` - Web browser automation (excluded in this example) + +## Common Issues + +### "Unauthorized" error + +Make sure you're using the **session API key** for agent-server calls, not your +Cloud API key: + +```python +# Wrong - Cloud API key on the agent-server +headers = {"Authorization": f"Bearer {OH_API_KEY}"} + +# Correct - session API key +headers = {"X-Session-API-Key": session_key} +``` + +### Only `finish` and `think` are present + +You almost certainly created the conversation with an `agent` object that +contained an `llm` but no `tools`. Pass the tools inline in the same request (see +"The Key Insight" above). + +### HTTP 422 when deleting a sandbox + +`DELETE /api/v1/sandboxes/{id}` also requires `sandbox_id` as a query parameter: +`DELETE /api/v1/sandboxes/{id}?sandbox_id={id}`. + +### Conversation never finishes + +The default timeout is 3 minutes. If your task is complex, inspect the +conversation in the UI (`https://app.all-hands.dev/conversations/{conv_id}`) or +fetch its events, and consider a simpler task or a longer timeout. + +## Architecture Notes + +```mermaid +flowchart TD + A["Cloud API (app.all-hands.dev)
- Authentication (Bearer OH_API_KEY)
- Sandbox lifecycle (create / list / delete)"] -->|creates| B["Agent-Server (sandbox-specific runtime URL)
- Auth via session API key (X-Session-API-Key)
- Agent configuration (LLM, tools)
- Conversation execution"] +``` + +**Key insight:** Agent customization happens at the **agent-server level**. The +simplest, reliable way to set tools is to pass them inline in the `agent` object +when creating the conversation. + +## Next Steps + +- See `../custom-agent-with-tool/` for adding completely custom tools. + +## APIs Used + +### Key Agent-Server APIs Used + +| Endpoint | Method | Purpose | +| --------------------------------------- | ------ | -------------------------------------------------------- | +| `/api/conversations` | POST | Create conversation (pass `agent.llm` and `agent.tools`) | +| `/api/conversations/{id}/run` | POST | Start conversation execution | +| `/api/conversations/{id}` | GET | Get conversation status | +| `/api/conversations/{id}/events/search` | GET | Get conversation events (tools, actions) | +| `/api/conversations/{id}` | DELETE | Delete the conversation | + +### Cloud APIs Used + +| Endpoint | Method | Purpose | +| ---------------------------------------- | ------ | ----------------------------------------- | +| `/api/v1/sandboxes` | POST | Create a sandbox | +| `/api/v1/sandboxes?id={id}` | GET | Poll sandbox status / read `exposed_urls` | +| `/api/v1/sandboxes/{id}?sandbox_id={id}` | DELETE | Delete the sandbox | + +## Related + + + + + + + + diff --git a/cookbook/custom-agent-with-pip-tool.mdx b/cookbook/custom-agent-with-pip-tool.mdx new file mode 100644 index 000000000..42addf8f2 --- /dev/null +++ b/cookbook/custom-agent-with-pip-tool.mdx @@ -0,0 +1,160 @@ +--- +title: Custom Pip Tool Agent +description: Load a custom tool from a published pip package via pip install --target and tool_module_qualnames. +icon: box +--- + +{/* GENERATED from OpenHands/enterprise-cookbook@main (custom-agent-with-pip-tool/README.md). Edit the source, not this file. */} + + + +Load a custom tool from a **published Python package** in OpenHands Cloud. The package +is installed via `pip install --target /workspace`, making it importable by the frozen +agent-server, which then loads and registers the tool via `tool_module_qualnames`. + +This is the natural evolution of `custom-agent-with-tool`: same loading mechanism +(declare the tool in `agent.tools`, map it in `tool_module_qualnames`), but the +*deploy* step becomes **"pip-install-into-working-dir"** instead of +**"upload source files."** + +The example uses **[oh-markdown-tool](https://github.com/jpshackelford/oh-markdown-tool)** +(`oh-markdown-tool[openhands]==0.2.1`), a real published package that provides structural +markdown editing (renumber sections, manage TOC, etc.). The agent fixes a markdown doc +with messy numbering and adds a table of contents. + +## How It Works + +1. **Create a sandbox** (`POST /api/v1/sandboxes`) and wait for `RUNNING`. +2. **Install the package** into `/workspace` via `pip install --target`: + ```bash + pip install --target /workspace --no-deps oh-markdown-tool[openhands]==0.2.1 + pip install --target /workspace mdformat pymarkdownlnt + ``` + The `--target` flag puts the package into the conversation's working directory, + which is on the agent-server's `sys.path`. We use `--no-deps` to skip dependencies + that are already **bundled** in the frozen agent-server (`openhands-sdk`, `pydantic`, + `rich`), then install only the **non-bundled** deps (`mdformat`, `pymarkdownlnt`) + explicitly. +3. **Create a sample markdown file** with messy numbering (sections 5, 10, 3). +4. **Create a conversation** that maps the tool via `tool_module_qualnames`: + ```python + "agent": { + "tools": [ + {"name": "terminal"}, + {"name": "file_editor"}, + {"name": "markdown_document"}, # the custom tool + ] + }, + "tool_module_qualnames": {"markdown_document": "oh_markdown_tool.tool"}, + ``` + The agent-server imports `oh_markdown_tool.tool`, which registers the tool (the + package's `tool.py` calls `register_tool("markdown_document", ...)` on import). +5. **Run and verify**: + - The tool appears in `SystemPromptEvent.tools` (registered). + - The tool appears in `ActionEvent` records (used). + - The file is actually modified (TOC added, sections renumbered 1, 2, 3). + +## Prerequisites + +```bash +pip install requests +export OH_API_KEY=your-openhands-cloud-api-key +export LLM_API_KEY=your-llm-api-key +``` + +Optional overrides (sensible defaults are used otherwise): + +```bash +export LLM_MODEL=litellm_proxy/claude-sonnet-4-5-20250929 +export LLM_BASE_URL=https://llm-proxy.app.all-hands.dev/ +``` + +## Run It + +```bash +python working_example.py # runs and cleans up the sandbox +python working_example.py --keep # leave the sandbox up for inspection +``` + + + **⚠️ Timing note**: If the script runs and deletes the sandbox too quickly, conversation + events may not have synced from the agent-server to the main API yet, making the + conversation appear empty or incomplete in the Cloud UI. To inspect conversation events + in real time, **run with `--keep`** to leave the sandbox alive, or add a delay before + cleanup. The script prints the conversation URL — you can view it while the sandbox is + still running. + + +Expected output: + +```text +[demo] === Verification === +[demo] registered tools: ['terminal', 'file_editor', 'Markdown Document Tool', 'finish', 'think'] +[demo] tools used: ['file_editor', 'markdown_document'] +[demo] PASS: tool 'markdown_document' is registered +[demo] PASS: tool 'markdown_document' was invoked by the agent +[demo] PASS: file was modified (TOC added, sections renumbered) +[demo] SUCCESS: the tool was loaded from the pip package and used. +``` + +## Why `pip install --target` (and Not Plain `pip install`)? + +On OpenHands Cloud the agent-server is a **frozen, self-contained binary** (built with +PyInstaller). A plain `pip install` targets the sandbox's *system* Python interpreter, +which the frozen server cannot see — so the module would never be importable. + +The conversation's **working directory is on the agent-server's import path**, so +`pip install --target /workspace` places the package where the server can actually +import it. The bundled dependencies (`openhands-sdk`, `pydantic`, `rich`) are already +available inside the frozen binary, so we skip them (`--no-deps`) and only install the +package's own code plus its *non-bundled* dependencies. + +This approach works for any published package, not just `oh-markdown-tool` — the key is +installing into the working directory and being aware of what's already bundled. + +## Verification (Same Technique as the Other Examples) + +Both this example and `custom-agent-with-tool` read the `SystemPromptEvent.tools` array +to confirm registration, and the `ActionEvent` records to confirm usage. This example +adds a third check: **did the tool actually do something?** We read the markdown file +after the run and confirm it was modified (TOC present, sections renumbered). + +## The Tool: `oh-markdown-tool` + +`oh-markdown-tool` is a real, published package ([PyPI](https://pypi.org/project/oh-markdown-tool/) | +[GitHub](https://github.com/jpshackelford/oh-markdown-tool)) that provides structural +markdown editing. It's built with the OpenHands SDK and demonstrates the full packaging +story: core library (no SDK dependency) + optional `[openhands]` extra for the agent +tool integration. + +The tool offers commands like: + +- `overview` — show document structure +- `renumber` — fix section numbering +- `toc_update` — add/update table of contents +- `move`, `insert`, `delete`, `promote`, `demote` — section operations +- `rewrap`, `lint`, `fix`, `cleanup` — formatting + +For this example we use `renumber` + `toc_update` to fix a messy doc. + +## Files + +| File | Purpose | +| -------------------- | ------------------------------------------------------------------------- | +| `working_example.py` | End-to-end runner: create sandbox → pip install → run → verify → clean up | + +## Cleanup + +Without `--keep` the sandbox is deleted automatically. With `--keep`, delete it when +done: + +```bash +curl -X DELETE "https://app.all-hands.dev/api/v1/sandboxes/?sandbox_id=" \ + -H "Authorization: Bearer $OH_API_KEY" +``` + + + Note: `DELETE /api/v1/sandboxes/{id}` requires `sandbox_id` as **both** the path + segment and a query parameter; omitting the query parameter returns HTTP 422 and + leaks the sandbox. + diff --git a/cookbook/custom-agent-with-tool.mdx b/cookbook/custom-agent-with-tool.mdx new file mode 100644 index 000000000..490e64c4b --- /dev/null +++ b/cookbook/custom-agent-with-tool.mdx @@ -0,0 +1,166 @@ +--- +title: Custom Agent With Tool +description: Add custom server-side tools via source file upload and tool_module_qualnames. +icon: wrench +--- + +{/* GENERATED from OpenHands/enterprise-cookbook@main (custom-agent-with-tool/README.md). Edit the source, not this file. */} + + + +Add a **completely custom, server-side tool** to an OpenHands Cloud agent — without +forking or rebuilding the agent-server. The agent-server loads your tool at +conversation-creation time via the `tool_module_qualnames` mechanism. + +The example ships a small demo tool (the **Bureau of Bug Registration**) and proves, +end to end, that the agent **registers**, **uses**, and **reports output from** it. + +Why this tool? It does something an LLM would never produce on its own and **could not +fake**: it assigns each bug a deterministic, hash-derived **Case ID** (e.g. `BUG-59C20D`) +plus a gloriously bureaucratic classification. Because the Case ID is a SHA-256 slice +of the bug report, the only way the agent's answer can contain the correct ID is if +it actually called the tool — making the demonstration unfalsifiable. + +## How It Works + +1. **Create a sandbox** (`POST /api/v1/sandboxes`) and wait for `RUNNING`. Read the + `AGENT_SERVER` URL and the `session_api_key` from the sandbox record. +2. **Deploy the tool** as an importable package in the conversation's working + directory, `/workspace/bug_registry/`, using the agent-server file API + (`POST /api/file/upload?path=...`). Two files are uploaded: `__init__.py` and + `tool.py` (the contents of `custom_tool_definition.py`). +3. **Create a conversation** (`POST /api/conversations`) that lists the tool in + `agent.tools` and maps it in `tool_module_qualnames`. +4. **Run and verify** (`POST .../run`, then read events). The custom tool must show + up in the `SystemPromptEvent.tools` list (registered) and as an `ActionEvent` + `tool_name` (used). + +### Why upload into the working directory (and not `pip install`)? + +On OpenHands Cloud the agent-server is a **frozen, self-contained binary** (built +with PyInstaller). A normal `pip install` targets a *different* Python interpreter +that the frozen server cannot see, so the module would never be importable and the +tool would fail to register. + +The conversation's **working directory is on the agent-server's import path**, so +dropping the package there makes `import rubber_duck.tool` work with no install +step. The tool's own imports (`openhands.sdk`, `pydantic`) resolve from inside the +frozen server, where they're always available. + +Uploading the file (rather than writing it through a shell heredoc) also means the +**tool source can contain anything** — quotes, `EOF` markers, backslashes — with no +escaping or injection pitfalls. + +## The Core Idea: Declare + Locate + +Creating a conversation with a custom tool is a two-part contract: + +- `agent.tools` — **which** tools to activate, by name: + `[{"name": "terminal"}, {"name": "file_editor"}, {"name": "bug_registry"}]` +- `tool_module_qualnames` — **where** a tool comes from, as an importable module: + `{"bug_registry": "bug_registry.tool"}` + +On conversation creation the agent-server imports each mapped module. Importing has +a **side effect**: the module's `register_tool("bug_registry", BugRegistryTool)` call +runs and registers the tool. If the name is requested but nothing registered it, +the server returns `ToolDefinition '' is not registered`. + +Built-in tools (`terminal`, `file_editor`, ...) don't need an entry — the server +already knows where those live. **Only your custom tools need a qualname mapping.** + +## Prerequisites + +```bash +pip install requests +export OH_API_KEY=your-openhands-cloud-api-key +export LLM_API_KEY=your-llm-api-key +``` + +Optional overrides (sensible defaults are used otherwise): + +```bash +export LLM_MODEL=litellm_proxy/claude-sonnet-4-5-20250929 +export LLM_BASE_URL=https://llm-proxy.app.all-hands.dev/ +``` + +## Run It + +```bash +python working_example.py # runs and cleans up the sandbox +python working_example.py --keep # leave the sandbox up for inspection +``` + + + **⚠️ Timing note**: If the script runs and deletes the sandbox too quickly, conversation + events may not have synced from the agent-server to the main API yet, making the + conversation appear empty or incomplete in the Cloud UI. To inspect conversation events + in real time, **run with `--keep`** to leave the sandbox alive, or add a delay before + cleanup. The script prints the conversation URL — you can view it while the sandbox is + still running. + + +Expected output: + +```text +[demo] === Verification === +[demo] registered tools: ['terminal', 'file_editor', 'bug_registry', 'finish', 'think'] +[demo] tools used: ['bug_registry'] +[demo] Case ID issued by tool: BUG-59C20D +[demo] PASS: custom tool 'bug_registry' is registered +[demo] PASS: custom tool 'bug_registry' was used by the agent +[demo] PASS: agent reported the tool's Case ID (BUG-59C20D) in its answer +[demo] SUCCESS: the custom tool was loaded and used in OpenHands Cloud. +``` + +## Verifying Registration (Same Technique as `custom-agent-no-browser`) + +The agent-server records a `SystemPromptEvent` whose `tools` array is the exact set +of tools the agent was given. Both examples read it the same way: + +```python +system_events = [e for e in items if e.get("kind") == "SystemPromptEvent"] +registered = [t.get("title") for t in system_events[0].get("tools", [])] +assert "bug_registry" in registered # registered / available +assert "bug_registry" in tools_used # actually invoked (from ActionEvents) +``` + +The verification also extracts the **Case ID** from the tool's observation (the ground +truth) and confirms that exact ID appears in the agent's final answer — proving the +tool's output shaped the response and the agent didn't fabricate it. + +## The Example Tool + +`custom_tool_definition.py` defines a `bug_registry` tool with the OpenHands SDK: a +`ToolDefinition` + typed `Action`/`Observation` + an `Executor`. It takes a `problem` +(and optional `code`) and deterministically derives a **hash-based Case ID** plus an +absurd bureaucratic classification ("Haunted Copy-Paste Residue", "Gaslighting Boolean", +etc.) from the SHA-256 of the report. Importing the module registers the tool. + +The prompt in `working_example.py` is **directive** — it explicitly asks the agent to +file the bug and report the official Case ID — so the happy path is deterministic and +unfalsifiable: the hash-derived ID can only come from the tool. This proves the tool is +registered, callable, returns output, and that output demonstrably reaches the agent's +answer. + +## Files + +| File | Purpose | +| --------------------------- | ----------------------------------------------------------------------------- | +| `working_example.py` | End-to-end runner: create sandbox -> deploy tool -> run -> verify -> clean up | +| `custom_tool_definition.py` | The custom tool, implemented with the OpenHands SDK (uploaded as `tool.py`) | + +## Cleanup + +Without `--keep` the sandbox is deleted automatically. With `--keep`, delete it when +done: + +```bash +curl -X DELETE "https://app.all-hands.dev/api/v1/sandboxes/?sandbox_id=" \ + -H "Authorization: Bearer $OH_API_KEY" +``` + + + Note: `DELETE /api/v1/sandboxes/{id}` requires `sandbox_id` as **both** the path + segment and a query parameter; omitting the query parameter returns HTTP 422 and + leaks the sandbox. + diff --git a/cookbook/custom-system-prompt.mdx b/cookbook/custom-system-prompt.mdx new file mode 100644 index 000000000..38f9a4bae --- /dev/null +++ b/cookbook/custom-system-prompt.mdx @@ -0,0 +1,425 @@ +--- +title: Custom System Prompt +description: Override the default OpenHands system prompt with a custom one for specialized agents. +icon: message +--- + +{/* GENERATED from OpenHands/enterprise-cookbook@main (custom-system-prompt/README.md). Edit the source, not this file. */} + + + +Override the OpenHands system prompt for one conversation with your own text, +using the Cloud **App Conversation API**. + +`POST /api/v1/app-conversations` accepts a `system_prompt` field. When set, +it **replaces** the default static system prompt for that conversation. The +per-conversation dynamic context — repo context, loaded skills, custom +secrets, current datetime — is still appended automatically, so your custom +prompt composes with whatever runtime context the sandbox provides. + +Feature shipped in OpenHands Enterprise **1.62.0** +([enterprise#335](https://github.com/OpenHands/enterprise/pull/335)) and is +also live on OpenHands Cloud. + +## When to Use This + +- Your agent has a specialised role (research assistant, code reviewer, + documentation writer) and the default coding-agent framing gets in the way. +- You're building a domain-specific product on top of OpenHands and want to + own the prompt without asking the agent to *ignore* the default one. +- You need to enforce specific behaviour or persona for a single + conversation without changing user- or org-level settings. + +For long-lived overrides, put the text in an **agent profile** instead; +`system_prompt` is a per-conversation override. + +## What Gets Replaced, and What Doesn't + +`SystemPromptEvent` is the first event in every conversation and carries +two structured fields: + +| Field | Contains | Overridden by `system_prompt`? | +| ----------------- | ---------------------------------------------------------------------------------------------- | --------------------------------------------------------- | +| `system_prompt` | The static system prompt (a `{cache_prompt, type, text}` object) | **Yes** — its `.text` becomes your custom prompt verbatim | +| `dynamic_context` | Runtime context: repo context, ``, ``, ``, host info | No — still appended by the server | + +`system_message_suffix` is a *separate* field on the same request and is +appended to the dynamic context; you can set both. + +## Anatomy of the Default System Prompt + +Before you replace the default prompt wholesale, it's worth knowing what +you're displacing. The SDK composes the prompt from a small set of named, +guarded sections. The references below are pinned to +[`software-agent-sdk@v1.50.1`](https://github.com/OpenHands/software-agent-sdk/tree/v1.50.1) +so the line numbers and content stay stable; bump the tag when auditing a +newer release. + +### Two blocks on one system message + +The default agent ships the prompt as a `SystemPromptEvent` with two +content blocks on a single `role: system` message +([`event/llm_convertible/system.py`](https://github.com/OpenHands/software-agent-sdk/blob/v1.50.1/openhands-sdk/openhands/sdk/event/llm_convertible/system.py)): + +| Block | Field on the event | What it carries | Cacheable | +| ----------- | ---------------------- | ---------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| 1 (static) | `system_prompt.text` | Identity, role, work habits, security policy, model-specific rules | **Yes** — marked `cache_prompt=True` | +| 2 (dynamic) | `dynamic_context.text` | Current datetime, repo context, loaded ``, custom ``, your `system_message_suffix` | **No** — left unmarked so the static prefix stays shared across conversations | + +The caching split is wired in +[`llm/llm.py::_apply_prompt_caching`](https://github.com/OpenHands/software-agent-sdk/blob/v1.50.1/openhands-sdk/openhands/sdk/llm/llm.py) +— for Anthropic-style prefix caching, only index 0 gets the cache marker. +Every conversation your account starts can hit the same cached static +block, which is the whole reason the two-block shape exists. + +### What goes into each block + +Static-tier sections live in +[`context/prompts/sections/static.py`](https://github.com/OpenHands/software-agent-sdk/blob/v1.50.1/openhands-sdk/openhands/sdk/context/prompts/sections/static.py); +dynamic-tier sections in +[`context/prompts/sections/dynamic.py`](https://github.com/OpenHands/software-agent-sdk/blob/v1.50.1/openhands-sdk/openhands/sdk/context/prompts/sections/dynamic.py). +The default composition order is pinned in +[`context/prompts/presets.py`](https://github.com/OpenHands/software-agent-sdk/blob/v1.50.1/openhands-sdk/openhands/sdk/context/prompts/presets.py): + +**Static block, in order** — `` and `` (identity), `` +(AGENTS.md or persistent-memory guidance), ``, +``, ``, ``, +``, ``, `` +(work habits), `` and `` (safety +policy), `` (only if `enable_browser`), +``, ``, ``, +``, and `` (per-model-family tweaks for +Claude, Gemini, GPT-5). + +**Dynamic block, in order** — ``, ``, +``, your `system_message_suffix` (raw, no wrapper), +``, and `` **last** on purpose: it's +the only per-conversation volatile value, so putting it at the tail keeps +the stable dynamic content a cache-friendly prefix even on providers that +cache the dynamic block too. + +Setting `system_prompt` on `POST /api/v1/app-conversations` **replaces the +entire static block above** — all 17-ish sections, verbatim text and all — +with your custom text. The dynamic block is unaffected. + +### Writing a custom `system_prompt` without breaking caching + +The static block's whole value is that it's the same bytes every time your +account hits the LLM. Anthropic's prefix cache keys on the exact byte +prefix: a one-character change splits one cached prefix into two, each +with its own cold-start cost on the next miss. OpenAI's and Gemini's +caches have the same shape of pitfall. So when you write your own +`system_prompt`: + +- **Don't interpolate per-conversation volatile values** into the text. + No `datetime.now()`, no request id, no conversation id, no user email, + no repo URL, no sandbox id, no working directory. Any of these in the + static block means every conversation gets its own cache entry — + effectively no caching at all. +- **Don't interpolate per-user profile fields** either (API key, display + name, org name). Even if the value changes rarely, it still shards the + cache per-user, and anything secret-shaped doesn't belong in a cached + blob. +- **If you need date-aware behaviour, read the dynamic block.** The server + always appends `` to `dynamic_context` — reference it + from the static text (e.g. "see `` for today's date") + instead of baking a timestamp in. +- **Keep the exact same bytes across runs that are logically "the same + agent".** If you tweak wording, do it on a deploy boundary, not inside + the request path. +- **Front-load the parts you're least likely to edit.** On a tail-only + edit, the longest unchanged prefix still hits the cache; on a prefix + edit, nothing does. +- **If you need per-conversation flavour, use `system_message_suffix`** + (goes into the uncached dynamic block) rather than templating it into + `system_prompt`. + +A sanity check: hash the string you're about to send +(`hashlib.sha256(system_prompt.encode()).hexdigest()[:12]`) and log it. If +that hash changes between two conversations that should be equivalent, you +have a cache leak. + +### `system_prompt` vs. skills vs. `system_message_suffix` — when to use which + +All three let you shape the agent's instructions, but they pay very +different context-window and caching costs: + +| Mechanism | Where it lands | Cost | Use for | +| ------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| `system_prompt` | Cached static block | Paid once per unique prompt, then free (within the cache window) | Identity, tone, hard rules, response format — things the agent should see on **every** turn of **every** conversation | +| **Skills** (`disabled_skills` on the start request, or your own via the plugin / sandbox-upload paths) | Dynamic `` block, with per-skill bodies loaded only on trigger | Catalog line costs tokens every turn; the full skill body only costs tokens on turns it fires | Specialised procedures, long reference material, service-specific recipes — things that matter **sometimes** | +| `system_message_suffix` | Dynamic block (uncached), appended after `` | Paid every turn, every conversation it's set on | Short per-conversation nudges that compose with the default without rewriting the full static text | + +Rule of thumb — **if the instruction applies to every turn of every +conversation this agent runs, it belongs in `system_prompt`. If it +applies sometimes, make it a skill. If it's a one-conversation tweak, +use `system_message_suffix`.** + +Resist the urge to pack long domain procedures into `system_prompt` just +because it feels tidier. Every token in the static block is a token the +LLM reads before responding to anything — a skill that fires on 1-in-20 +turns pays its full-body cost \~5% as often as the static block does. + +## How It Works + +### 1. Start the conversation + +`POST /api/v1/app-conversations` is asynchronous: it returns a +[`AppConversationStartTask`](/) whose `id` is the +**task id**, not the conversation id. Omit `sandbox_id` and the App Server +provisions a fresh sandbox for you. + +```python +payload = { + "system_prompt": "You are a research assistant...", + "initial_message": { + "role": "user", + "content": [{"type": "text", "text": "Say hello."}], + }, + "title": "custom-system-prompt demo", +} +task = requests.post( + f"{base_url}/api/v1/app-conversations", + headers={"Authorization": f"Bearer {api_key}"}, + json=payload, +).json() +task_id = task["id"] +``` + +### 2. Wait for `app_conversation_id` + +Poll `/api/v1/app-conversations/start-tasks?ids=` until the task +reaches `READY` and hands back `app_conversation_id` (and, if the server +provisioned one, `sandbox_id`). The endpoint is a "search by ids" +lookup — `ids` is a required, repeatable query parameter and the +response is a JSON array of the matching tasks. + +```python +while not conv_id: + time.sleep(3) + item = requests.get( + f"{base_url}/api/v1/app-conversations/start-tasks", + headers=headers, + params={"ids": task_id}, + ).json()[0] + if item["status"] == "ERROR": + raise RuntimeError(item.get("detail")) + conv_id = item.get("app_conversation_id") + sandbox_id = item.get("sandbox_id") or sandbox_id +``` + +### 3. Verify from the `SystemPromptEvent` + +The Cloud events endpoint mirrors the agent-server view, so you can read +the event without needing the sandbox's `session_api_key`. + +```python +event = requests.get( + f"{base_url}/api/v1/conversation/{conv_id}/events/search", + headers=headers, + params={"kind__eq": "SystemPromptEvent", "limit": 1}, +).json()["items"][0] + +assert custom_prompt in event["system_prompt"]["text"] +``` + +Note the shape: `event["system_prompt"]` is a `{cache_prompt, type, text}` +object — the actual prompt string is at `.text`. + +### 4. Cleanup + +`DELETE /api/v1/app-conversations/{id}` frees the conversation. Delete the +sandbox separately when you're done with it. The sandbox delete endpoint +is `DELETE /api/v1/sandboxes/{id}` and additionally requires `sandbox_id` +as a query parameter — pass the same id in both places, or the server +returns HTTP 422. + +```python +requests.delete(f"{base_url}/api/v1/app-conversations/{conv_id}", headers=headers) +requests.delete( + f"{base_url}/api/v1/sandboxes/{sandbox_id}", + headers=headers, + params={"sandbox_id": sandbox_id}, +) +``` + +## Prerequisites + +```bash +pip install requests +export OH_API_KEY="your-api-key" # Cloud: Profile → API Keys. + # OHE: Settings → API Keys on your instance. +``` + +You do **not** need to set LLM credentials — the App Server uses whatever +LLM your account is configured with. + +## Run It + +```bash +python custom_system_prompt.py + +# or against an OpenHands Enterprise instance: +python custom_system_prompt.py --base-url https://your-ohe.example.com + +# supply your own prompt / message: +python custom_system_prompt.py \ + --prompt "You are Sir Reginald, a pirate. Every reply starts with 'Arrr!'" \ + --message "Say hello." + +# keep resources for inspection (no cleanup at the end): +python custom_system_prompt.py --keep +``` + +The script starts a conversation with `system_prompt` set, fetches the first +`SystemPromptEvent`, asserts the custom text is there, and cleans up. +Exit status is non-zero if verification fails. + +### Real output + +Captured against an OpenHands Enterprise 1.67.0 instance: + +```text +=== start conversation === + custom prompt: You are a research assistant. Answer questions concisely and accurately. When wr... + start-task status: WAITING_FOR_SANDBOX + start-task status: WAITING_FOR_SANDBOX + start-task status: WAITING_FOR_SANDBOX + start-task status: WAITING_FOR_SANDBOX + start-task status: SETTING_UP_SKILLS + start-task status: SETTING_UP_SKILLS + start-task status: STARTING_CONVERSATION + start-task status: STARTING_CONVERSATION + start-task status: READY +conversation: 7698ab13bb994b3181ea01d23652a2ca +sandbox: 3U13tOWV00GZpo1bhnhMBf + +=== verify SystemPromptEvent === + system_prompt.text (124 chars): You are a research assistant. Answer questions concisely and accurately. When writing code, focus on clarity and simplic... + dynamic_context.text (47806 chars) — appended automatically; contains repo/skills/secrets/datetime blocks + + PASS: custom prompt found in SystemPromptEvent + +=== cleanup === + deleted conversation 7698ab13bb994b3181ea01d23652a2ca + deleted sandbox 3U13tOWV00GZpo1bhnhMBf +``` + +## Prompt Gallery + +Swap `--prompt` (or `SYSTEM_PROMPT`) for any of these to try them out. + +### Research assistant + +```text +You are a research assistant. Answer questions concisely and accurately. +When writing code, focus on clarity and simplicity. Always cite your sources +when making factual claims. +``` + +### Code reviewer + +```text +You are a code reviewer. Analyze code for correctness, security, and +maintainability. Provide specific, actionable feedback. Focus on material +issues, not style preferences. +``` + +### Documentation writer + +```text +You are a technical documentation writer. Write clear, accurate, and +comprehensive documentation. Use examples to illustrate concepts. +Structure content with clear headings and logical flow. +``` + +### Data analyst + +```text +You are a data analyst. Analyze data to extract insights and patterns. +Use appropriate statistical methods and visualizations. Explain your +findings clearly with supporting evidence. +``` + +## Composition with `system_message_suffix` + +`system_message_suffix` is a separate field appended to the dynamic +context. You can use both together: + +```python +payload = { + "system_prompt": "You are a research assistant...", + "system_message_suffix": "Focus on academic papers published after 2020.", + ... +} +``` + +The resulting prompt structure is: + +```text +[system_prompt.text — your custom static prompt] + +[dynamic_context.text — repo context, skills, secrets, datetime] +[system_message_suffix — appended to the dynamic context] +``` + +## Planning Agent (`agent_type=plan`) + +When `agent_type=plan` is set alongside `system_prompt`, the custom prompt +still replaces the built-in planning static prompt, but the planning tools +and workflow instructions are kept — the agent keeps its ability to create +and update plans, with your text as the foundation. + +## ACP Agents + +For ACP (Agent Communication Protocol) agents, which delegate to external +CLIs (Claude Code, Gemini CLI, etc.), `system_prompt` is **ignored** with a +server-side warning (`app_conversation_start:system_prompt_ignored_for_acp_agent`). +ACP agents own their own system prompt and cannot be overridden via the REST +API. + +## Feature Availability + +- OpenHands Cloud (currently deployed) +- OpenHands Enterprise **1.62.0+** + ([enterprise#335](https://github.com/OpenHands/enterprise/pull/335)) + +To verify on a specific deployment, look for `system_prompt` in the +`AppConversationStartRequest` schema: + +```bash +curl -s https://your-deployment/openapi.json \ + | jq '.components.schemas["AppConversationStartRequest-Input"].properties.system_prompt' +``` + +## APIs Used + +| Endpoint | Method | Purpose | +| ----------------------------------------- | ------ | --------------------------------------------- | +| `/api/v1/app-conversations` | POST | Start the conversation (returns a start task) | +| `/api/v1/app-conversations/start-tasks` | GET | Poll the start task for `app_conversation_id` | +| `/api/v1/conversation/{id}/events/search` | GET | Read `SystemPromptEvent` to verify | +| `/api/v1/app-conversations/{id}` | DELETE | Delete the conversation | +| `/api/v1/sandboxes/{id}?sandbox_id={id}` | DELETE | Delete the sandbox | + +All calls use `Authorization: Bearer `. + +## Related + + + + same App Conversation API pattern, showing how to attach and read back arbitrary metadata. + + + + start a conversation with a plugin pre-loaded, using the same start-task polling flow. + + + + configure which tools the agent has access to (different customisation axis). + + + + + + diff --git a/cookbook/disabled-skills.mdx b/cookbook/disabled-skills.mdx new file mode 100644 index 000000000..18763dc76 --- /dev/null +++ b/cookbook/disabled-skills.mdx @@ -0,0 +1,534 @@ +--- +title: Disabled Skills +description: Persist an account-level deny-list of skills so every conversation starts with them disabled. +icon: ban +--- + +{/* GENERATED from OpenHands/enterprise-cookbook@main (disabled-skills/README.md). Edit the source, not this file. */} + + + +Keep specific OpenHands skills out of every conversation an account starts, +using the Cloud **Settings API** — the same endpoint the UI's *Settings → +Skills* page toggles. Once installed, the deny-list applies to every +`POST /api/v1/app-conversations` call that account makes, without the +caller having to pass anything on each request. + +`POST /api/v1/settings` with `{"disabled_skills": [...]}` persists an +**account-level** deny-list. On every conversation start the App Server +computes `effective_disabled_skills = member ∪ profile ∪ request` and drops +matching skill names from the `` block that ships in the first +`SystemPromptEvent`. A skill disabled at any level stays off. + +Feature shipped in OpenHands Enterprise **1.62.0** +([enterprise#335](https://github.com/OpenHands/enterprise/pull/335)) and is +also live on OpenHands Cloud. + +## When to Use This + +Prefer the **account-level** setting for anything policy-shaped: + +- The team should never see a certain skill (e.g. no external git + integrations on an internal build; no `docker` when your sandbox base + image can't run nested Docker). +- The deny-list must be enforced consistently — you can't accidentally + start a conversation without it because you forgot the field. +- You don't want callers of the API to have to remember to include + `disabled_skills` on every request. + +Reach for the **per-request** field (`disabled_skills` on +`POST /api/v1/app-conversations`) only as an escape hatch: + +- One-off runs that need extra skills off, on top of the account default. +- Ad-hoc experiments where you're trying deny-lists without touching the + persisted setting. + +## Precedence + +`effective_disabled_skills` (from +`openhands/app_server/app_conversation/live_status_app_conversation_service.py`) +takes the union of three sources, order-preserving, de-duplicated: + +| Level | Where it lives | How to set it | +| ---------------------- | ------------------------------------------------------- | --------------------------------------------------------------------- | +| Account (member) | `user.disabled_skills` | `POST /api/v1/settings` with `disabled_skills: [...]` — this tutorial | +| Launched Agent Profile | `agent_context.disabled_skills` on the resolved profile | `POST /api/v1/settings/profiles/{name}` | +| Per-request | `disabled_skills` on the start request | `POST /api/v1/app-conversations` with `disabled_skills: [...]` | + +A name absent from the discovered skill catalog is a **harmless no-op** — +you can safely include speculative names without failing the request. Names +are matched exactly (case-sensitive) against the `` values returned by +`GET /api/v1/skills/search?q=` (a bare call with no `q` returns +microagents mixed in; pass a prefix like `?q=github` to see the skill names +that appear in the `` block). + +## ⚠ Caveat: Deny-First Means New OpenHands Skills Opt You In + +The current design is a **deny-list**, not an allow-list: anything that isn't +explicitly disabled is loaded. That is deliberately drift-tolerant for most +callers — a name you listed that no longer exists is a no-op, so your config +survives rename/removal — but it has one consequence worth understanding +before you ship this to production: + + + **When the OpenHands team adds a new built-in skill in a future SDK + release, every account whose `disabled_skills` doesn't name it will start + loading it on the next conversation, with no code change on your side.** + + +For most teams that's the desired behaviour: you get new capabilities for +free. But it's a problem when: + +- **Code (yours or an agent's) references skills by name.** A new skill + whose name collides with a trigger keyword or auto-injection rule you + depend on can start firing on turns it didn't fire on yesterday, changing + observable behaviour without a deploy on your side. +- **The deny-list is policy, not preference.** If `disabled_skills` is + there because an auditor said "no external git integrations", the correct + guarantee is "no git integrations ever", not "no git integrations we knew + about at the time". A new `gerrit` or `codecommit` skill would quietly + bypass that intent. +- **You need a stable per-turn context budget.** New skills (even if never + invoked) still show up in the `` block of the dynamic context and + consume tokens. + +### Lock-down pattern: snapshot the catalog, then deny everything else + +When you need tight control, treat the current skill catalog as the +authoritative set and deny everything in it except the names you want to +keep. This inverts the deny-list into an effective allow-list while still +using the only mechanism the server exposes. + +The authoritative source for "what skills does the agent see by default" +is the `` block of a `SystemPromptEvent` on a probe conversation +started with **no** `disabled_skills` set. (`GET /api/v1/skills/search` +without a `q` prefix returns microagents rather than the SDK skills that +appear in ``, so parsing the event is the reliable path — the +same path `disabled_skills.py` uses to verify absence.) + +```python +import re +import requests + +headers = {"Authorization": f"Bearer {api_key}"} +_SKILLS_BLOCK = re.compile(r"(.*?)", re.DOTALL) +_SKILL_NAME = re.compile(r"([^<]+)") + +# 1. Clear any existing deny-list so the probe sees the full catalog. +requests.post( + f"{base_url}/api/v1/settings", headers=headers, json={"disabled_skills": []}, +) + +# 2. Start a probe conversation and poll start-tasks until READY (omitted +# for brevity — see custom-system-prompt/custom_system_prompt.py). +probe_conv_id = start_and_wait(base_url, headers) + +# 3. Read SystemPromptEvent and extract every in . +event = requests.get( + f"{base_url}/api/v1/conversation/{probe_conv_id}/events/search", + headers=headers, + params={"kind__eq": "SystemPromptEvent", "limit": 1}, +).json()["items"][0] + +skills_block = _SKILLS_BLOCK.search(event["dynamic_context"]["text"]).group(1) +all_skill_names = set(_SKILL_NAME.findall(skills_block)) + +# 4. Decide the small set you *do* want. +allowed = {"github", "linear", "uv"} + +# 5. Deny the complement. +requests.post( + f"{base_url}/api/v1/settings", + headers=headers, + json={"disabled_skills": sorted(all_skill_names - allowed)}, +) +``` + +The probe is a one-time cost per catalog refresh; cache +`all_skill_names` and reuse it until the next SDK upgrade. + +Operational notes for teams running this as policy: + +- **Re-snapshot on every SDK or platform upgrade.** A new build may ship + new skill names; your deny-list won't cover them until you refresh it. + Wire the snapshot step into your deploy pipeline (or run it on a + schedule) rather than treating the deny-list as set-and-forget. +- **Diff before you apply.** `set(current) ^ set(snapshot)` tells you + exactly which skill names appeared or disappeared since the last run — + surface that in a code review or audit log so a human owns the decision + about any newcomer. +- **The agent profile is the right place to persist a locked-down set.** + `POST /api/v1/settings/profiles/{name}` holds a `disabled_skills` list + that applies on top of the account-level one, so a `"locked-down"` + profile can carry the full complement without the account default + having to repeat it. + +An explicit allow-list field isn't currently exposed by the API; snapshot + +deny-the-rest is the supported workaround. + +## How It Works + +### 1. Install the account-level deny-list + +```python +requests.post( + f"{base_url}/api/v1/settings", + headers={"Authorization": f"Bearer {api_key}"}, + json={"disabled_skills": ["github", "gitlab", "bitbucket"]}, +) +``` + +This is a partial settings save — the server deep-merges, so sending just +this one field leaves every other setting untouched. It's the exact call +the UI's *Settings → Skills* page makes via +`useSkillMutations.saveDisabledSkills` in +[`frontend/src/hooks/mutation/use-skill-mutations.ts`](https://github.com/OpenHands/enterprise/blob/main/frontend/src/hooks/mutation/use-skill-mutations.ts). + +One footgun: the server treats a `null` payload as "no change" (it copies +the previously-persisted list forward). To clear the deny-list, send `[]`. +`effective_disabled_skills` treats `None` and `[]` identically, so the +observable behaviour is the same. + +### 2. Start a conversation with nothing on the request + +```python +requests.post( + f"{base_url}/api/v1/app-conversations", + headers=headers, + json={ + "initial_message": { + "role": "user", + "content": [{"type": "text", "text": "Say hello."}], + }, + "title": "no per-request disabled_skills", + }, +) +``` + +No `disabled_skills` field is sent — the account-level setting is doing +all the work. + +### 3. Verify from the `SystemPromptEvent` + +The `SystemPromptEvent` is the first event in every conversation. Its +`dynamic_context.text` contains a `...` block listing +every skill the agent can invoke: + +```xml + +The following skills are available. ... + + + github-resilience + ... + + ... + + +``` + +The script parses out every `` and asserts none of the denied names +are present: + +```python +event = requests.get( + f"{base_url}/api/v1/conversation/{conv_id}/events/search", + headers=headers, + params={"kind__eq": "SystemPromptEvent", "limit": 1}, +).json()["items"][0] + +skills_block = re.search( + r"(.*?)", + event["dynamic_context"]["text"], + re.DOTALL, +).group(1) +loaded = re.findall(r"([^<]+)", skills_block) + +assert set(disabled_names).isdisjoint(loaded) +``` + +See the [`custom-system-prompt`](/cookbook/custom-system-prompt) tutorial for +the full shape of `SystemPromptEvent`. + +### 4. Cleanup and restore + +```python +requests.delete(f"{base_url}/api/v1/app-conversations/{conv_id}", headers=headers) +requests.delete( + f"{base_url}/api/v1/sandboxes/{sandbox_id}", + headers=headers, + params={"sandbox_id": sandbox_id}, +) +requests.post( + f"{base_url}/api/v1/settings", + headers=headers, + json={"disabled_skills": original or []}, +) +``` + +The script always restores the original account setting, even on error +(via `try / finally`), so a failed run never leaves the account with a +stuck deny-list. + +## Prerequisites + +```bash +pip install requests +export OH_API_KEY="your-api-key" # Cloud: Profile → API Keys. + # OHE: Settings → API Keys on your instance. +``` + +The account whose API key you use is the one whose `disabled_skills` +setting the script mutates and then restores. + +## Run It + +```bash +# default: 'no-git-integrations' gallery, account-level only +python disabled_skills.py + +# pick a different gallery +python disabled_skills.py --gallery no-docker +python disabled_skills.py --gallery no-github-automations + +# explicit skill names (overrides --gallery) +python disabled_skills.py --disable docker kubernetes pdflatex + +# also start a second conversation with additional per-request skills +# to demonstrate account ∪ request: +python disabled_skills.py --per-request + +# target an OpenHands Enterprise instance: +python disabled_skills.py --base-url https://your-ohe.example.com + +# keep conversations for inspection (account setting is still restored): +python disabled_skills.py --keep +``` + +The script: + +1. Reads and remembers the current account-level `disabled_skills`. +2. Installs the requested deny-list via `POST /api/v1/settings`. +3. Starts a conversation with **no** `disabled_skills` on the request, + proving the account-level setting alone keeps the skills out. +4. Parses `` in `dynamic_context.text` and asserts every disabled + name is absent. +5. Optionally repeats with per-request `disabled_skills` to show union. +6. Deletes the conversation(s) and sandbox(es), and restores the original + account-level setting — the beta instance is left as it was found. + +Exit status is non-zero if any verification fails. + +### Real output + +Captured against `https://app.all-hands.dev` with `--per-request`: + +```text +=== install account-level deny-list === + disabling: ['github', 'gitlab', 'bitbucket', 'bitbucket-cloud', 'bitbucket-data-center', 'azure-devops'] + previous account setting: [] + new account setting: ['github', 'gitlab', 'bitbucket', 'bitbucket-cloud', 'bitbucket-data-center', 'azure-devops'] + +=== conversation 1: no per-request disabled_skills === + start-task status: WAITING_FOR_SANDBOX + start-task status: WAITING_FOR_SANDBOX + start-task status: WAITING_FOR_SANDBOX + start-task status: WAITING_FOR_SANDBOX + start-task status: WAITING_FOR_SANDBOX + start-task status: SETTING_UP_SKILLS + start-task status: SETTING_UP_SKILLS + start-task status: STARTING_CONVERSATION + start-task status: STARTING_CONVERSATION + start-task status: READY + conversation: 445d3864b33f4ad594522abddee5169b sandbox: 3sHSpWbMEFoSvEzl9g7NyN + account-only: loaded 103 skills + - github absent + - gitlab absent + - bitbucket absent + - bitbucket-cloud absent + - bitbucket-data-center absent + - azure-devops absent + PASS (account-only): all denied skills absent from SystemPromptEvent + +=== conversation 2: per-request disabled_skills = ['flarglebargle'] (union should be ['github', 'gitlab', 'bitbucket', 'bitbucket-cloud', 'bitbucket-data-center', 'azure-devops', 'flarglebargle']) === + start-task status: WAITING_FOR_SANDBOX + start-task status: WAITING_FOR_SANDBOX + start-task status: WAITING_FOR_SANDBOX + start-task status: WAITING_FOR_SANDBOX + start-task status: WAITING_FOR_SANDBOX + start-task status: SETTING_UP_SKILLS + start-task status: SETTING_UP_SKILLS + start-task status: STARTING_CONVERSATION + start-task status: READY + conversation: f73fc9ad970a4cb88f1030851f47f8a9 sandbox: 2V2xzlr0wGG6oRLLho59jf + account + request: loaded 102 skills + - github absent + - gitlab absent + - bitbucket absent + - bitbucket-cloud absent + - bitbucket-data-center absent + - azure-devops absent + - flarglebargle absent + PASS (account + request): all denied skills absent from SystemPromptEvent + +=== cleanup === + deleted conversation 445d3864b33f4ad594522abddee5169b + deleted sandbox 3sHSpWbMEFoSvEzl9g7NyN + deleted conversation f73fc9ad970a4cb88f1030851f47f8a9 + deleted sandbox 2V2xzlr0wGG6oRLLho59jf + +=== restore original account-level setting === + restored account setting: [] +``` + +Conversation 1 loaded 103 skills; conversation 2 loaded 102 (the extra +`flarglebargle` denial removed one). Every denied name is absent from +the `` block of the `SystemPromptEvent`. + +## Deny-List Gallery + +Common recipes, all exposed through `--gallery`: + +### `no-git-integrations` + +Drop every git-hosting integration skill. Useful for internal-only +environments or when you're deliberately keeping the agent off customer +repos. + +```python +[ + "github", + "gitlab", + "bitbucket", + "bitbucket-cloud", + "bitbucket-data-center", + "azure-devops", +] +``` + +### `no-browser` + +Turn off skills whose primary value is browsing/preview flows. (The +browser *tool* itself is off via the agent config, not this deny-list; +see [`../custom-agent-no-browser/`](/cookbook/custom-agent-no-browser).) + +```python +["frontend-design", "vercel"] +``` + +### `no-docker` + +Keep the agent out of Docker and Kubernetes skills — appropriate when +your sandbox base image doesn't support nested Docker. + +```python +["docker", "kubernetes"] +``` + +### `no-github-automations` + +Keep the general `github` skill available but hide the automation +recipes (workflows, monitors, watchdogs) if they're not for this team. + +```python +[ + "github-actions", + "github-agents-md-maintainer", + "github-delivery-watchdog", + "github-issue-to-pr", + "github-issue-triage", + "github-pr-review", + "github-pr-reviewer", + "github-repo-monitor", + "github-stale-ci-pr-closer", +] +``` + +Roll your own with `--disable name1 name2 name3`. Skill names come from +`GET /api/v1/skills/search?q=`. + +## Per-Request Escape Hatch + +Set `disabled_skills` on the start request to *extend* the deny-list for +a single conversation: + +```python +requests.post( + f"{base_url}/api/v1/app-conversations", + headers=headers, + json={ + "disabled_skills": ["flarglebargle"], + "initial_message": { + "role": "user", + "content": [{"type": "text", "text": "Say hello."}], + }, + "title": "one-off deny-list bump", + }, +) +``` + +The three levels union — you can't *re-enable* an account- or +profile-level denial by omitting it here. If the account has `[github]` +and the request sends `[docker]`, the effective deny-list is +`[github, docker]`. + +## Feature Availability + +- OpenHands Cloud (currently deployed) +- OpenHands Enterprise **1.62.0+** + ([enterprise#335](https://github.com/OpenHands/enterprise/pull/335)) + +To verify on a specific deployment, look for `disabled_skills` in both the +`GETSettingsModel` and the `AppConversationStartRequest-Input` schemas: + +```bash +curl -s https://your-deployment/openapi.json | jq ' + .components.schemas.GETSettingsModel.properties.disabled_skills, + .components.schemas["AppConversationStartRequest-Input"].properties.disabled_skills +' +``` + +To confirm the endpoint you're calling is the same one the UI uses, +grep the enterprise frontend: + +```bash +gh repo clone OpenHands/enterprise +grep -rn "disabled_skills" enterprise/frontend/src/hooks/mutation/ +# -> use-skill-mutations.ts calls SettingsService.saveSettings({ disabled_skills }) +# -> settings-service.api.ts posts to /api/v1/settings +``` + +## APIs Used + +| Endpoint | Method | Purpose | +| ----------------------------------------- | ------ | ---------------------------------------------------- | +| `/api/v1/settings` | GET | Read current account `disabled_skills` (for restore) | +| `/api/v1/settings` | POST | Install / clear the account-level deny-list | +| `/api/v1/app-conversations` | POST | Start the conversation (`disabled_skills` optional) | +| `/api/v1/app-conversations/start-tasks` | GET | Poll the start task for `app_conversation_id` | +| `/api/v1/conversation/{id}/events/search` | GET | Read `SystemPromptEvent` to verify | +| `/api/v1/app-conversations/{id}` | DELETE | Delete the conversation | +| `/api/v1/sandboxes/{id}?sandbox_id={id}` | DELETE | Delete the sandbox | +| `/api/v1/skills/search?q=` | GET | Discover valid skill names for the deny-list | + +All calls use `Authorization: Bearer `. + +## Related + + + + sibling tutorial covering the other half of enterprise#335: replacing the static system prompt. + + + + turn off the browser tool (a different customisation axis; deny-list here operates on skills). + + + + start a conversation with a plugin pre-loaded, using the same start-task polling flow. + + + + + + diff --git a/cookbook/finish-callback.mdx b/cookbook/finish-callback.mdx new file mode 100644 index 000000000..3a6e9d502 --- /dev/null +++ b/cookbook/finish-callback.mdx @@ -0,0 +1,289 @@ +--- +title: Finish Callback +description: Notify an external URL when a conversation finishes using a Stop hook. +icon: bell +--- + +{/* GENERATED from OpenHands/enterprise-cookbook@main (finish-callback/README.md). Edit the source, not this file. */} + + + +A self-contained example showing how to **notify an external URL the moment a +conversation finishes**, using a **Stop hook** — instead of finding out only by +polling. When the agent reaches a terminal state, the hook `POST`s to a URL you +control, optionally attaching a payload file's contents. + +This is the "push instead of poll" pattern: keep your existing polling loop as a +safety net, but let the callback wake you up immediately in the common case. + +[![Load Finish Callback](https://img.shields.io/badge/Load%20Finish%20Callback-blue)](https://app.all-hands.dev/launch?plugins=W3sic291cmNlIjogImdpdGh1YjpPcGVuSGFuZHMvZW50ZXJwcmlzZS1jb29rYm9vayIsICJyZWYiOiAibWFpbiIsICJyZXBvX3BhdGgiOiAiZmluaXNoLWNhbGxiYWNrL29oLWZpbmlzaC1jYWxsYmFjayJ9XQ%3D%3D\&message=Say%20hello%20and%20then%20finish.) + + + **About the badge:** clicking it loads the plugin into a fresh conversation, + but the `/launch` route only carries `plugins` + `message` — **not secrets**. + Without `OH_CALLBACK_URL` the hook is a deliberate no-op, so the badge is a + "see the plugin load" demo. To actually receive a callback you must supply the + secrets, which means the API path below (`load_finish_callback.py`) or your own + call to `POST /api/v1/app-conversations` with a `secrets` field. + + + + **No customer information lives in this plugin.** The callback URL, an optional + shared-secret token, and an optional payload file path all come from + **conversation secrets** at start time. The repo ships only a local test + receiver so you can prove the end-to-end flow against your own machine. + + +## What's in the Box + +```text +finish-callback/ +├── callback_receiver.py # tiny stdlib web server (the demo receiver) +├── load_finish_callback.py # turnkey: start a conversation + pass the secrets +├── example-payload.json # optional POST body you can point the hook at +└── oh-finish-callback/ # the plugin (load this into a conversation) + ├── .claude-plugin/ + │ └── plugin.json # plugin manifest + ├── hooks/ + │ ├── hooks.json # the Stop hook (this is what actually runs) + │ └── on_stop.sh # readable source of the inline hook script + └── skills/ + └── oh-finish-callback/ + └── SKILL.md # docs (auto-loaded) +``` + +## How It Works + +```mermaid +flowchart TD + A["Agent finishes its work → conversation reaches FINISHED"] --> B["Stop hook fires (before the stop is finalized)"] + B --> B1["reads OH_CALLBACK_URL / OH_CALLBACK_TOKEN / OH_CALLBACK_PAYLOAD from env"] + B1 --> B2["builds the body: your payload file, or a small default JSON envelope"] + B2 --> B3["POSTs to your URL (async, best-effort, short timeout)"] + B3 --> C["Agent stops normally (the hook always exits 0 — it never blocks)"] + C --> D["Your receiver gets the POST and reacts immediately"] +``` + +## Configuration + +Everything is supplied as **conversation secrets** — nothing is hard-coded: + +| Variable | Required | Meaning | +| --------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------- | +| `OH_CALLBACK_URL` | Yes | Where to `POST`. If unset, the hook is a harmless no-op. | +| `OH_CALLBACK_TOKEN` | No | Sent as the `X-Callback-Token` header so your receiver can verify the caller. | +| `OH_CALLBACK_PAYLOAD` | No | Path (inside the sandbox) to a JSON file whose raw contents become the POST body. Omit it to send the default envelope. | + +Default body when no payload file is given: + +```json +{"status": "finished", "session_id": "", "finished_at": ""} +``` + +## Run It + +You need two things reachable from the sandbox: a **running receiver** and a +**public URL** that forwards to it. The receiver is stdlib-only; the loader +needs `requests`: + +```bash +pip install requests +``` + +You'll also need a Cloud API key (`export OH_API_KEY="sk-oh-..."`). + +### 1. Start the receiver + +```bash +cd finish-callback +python callback_receiver.py --port 8000 +# add --token s3cr3t to require the X-Callback-Token header +``` + +It prints every POST it receives (pretty-printed JSON) and replies `204`. + +### 2. Expose it to the internet + +The sandbox runs in the cloud, so it needs a public URL to reach your laptop. +Use any tunnel, e.g.: + +```bash +# examples — pick whichever you have +cloudflared tunnel --url http://localhost:8000 +ngrok http 8000 +``` + +Note the public `https://…` URL it gives you. That's your `OH_CALLBACK_URL`. + +### 3. Start a conversation with the plugin loaded + +Use the bundled turnkey helper. It loads the plugin **and** passes the callback +settings as conversation secrets, so the Stop hook picks them up as environment +variables: + +```bash +cd finish-callback +export OH_API_KEY="sk-oh-..." # Cloud API key +python load_finish_callback.py \ + --callback-url "https://your-tunnel.example/oh_finish" \ + --callback-token "s3cr3t" + +# Expected: when the agent finishes, your receiver prints a POST like: +# {"status": "finished", "session_id": "...", "finished_at": "..."} +``` + +To send your own body instead of the default envelope, add +`--callback-payload "/path/in/sandbox/body.json"` (the path is resolved inside +the sandbox, not on your laptop). + +**Prefer the generic loader?** [`load-plugin`](/cookbook/load-plugin) does the same +thing with `--secret` flags: + +```bash +cd ../load-plugin +python load_plugin.py \ + --repo-path finish-callback/oh-finish-callback \ + --message "Say hello and then finish." \ + --secret OH_CALLBACK_URL="https://your-tunnel.example/oh_finish" \ + --secret OH_CALLBACK_TOKEN="s3cr3t" +``` + + + **Heads-up:** the callback fires on **every** transition to `FINISHED` — so it + also covers follow-up messages you send later, not just the first run. + + +## Verify the Hook Locally (No Sandbox Needed) + +You can exercise the exact hook script against a local receiver in one shell: + +```bash +cd finish-callback +python callback_receiver.py --port 8000 & # start receiver + +echo '{"event_type":"Stop"}' \ + | OPENHANDS_SESSION_ID="local-test" \ + OH_CALLBACK_URL="http://127.0.0.1:8000/oh_finish" \ + sh oh-finish-callback/hooks/on_stop.sh # run the hook + +# The receiver prints the POST it just got. +``` + +## The Hook + +The magic is in [`hooks/hooks.json`](https://github.com/OpenHands/enterprise-cookbook/blob/main/finish-callback/oh-finish-callback/hooks/hooks.json): + +```json +{ + "hooks": { + "Stop": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "cat >/dev/null; ... curl -s -m 10 -X POST \"$OH_CALLBACK_URL\" ...; exit 0", + "timeout": 15, + "async": true + } + ] + } + ] + } +} +``` + +**How it works:** + +1. **`Stop`** — runs when the agent tries to finish (the terminal-state moment). +2. **`matcher: "*"`** — Stop hooks aren't tool-specific, so match everything. +3. **`type: "command"`** — the `command` is a POSIX-sh script run via `/bin/sh -c`. +4. **`async: true`** — fire-and-forget, so the callback never delays finishing. +5. **Exit codes:** `0` = allow the agent to finish (this hook always does); + `2` would *block* finishing (we deliberately never do that). + +The script reads its config from the environment, builds the body, and `curl`s +your URL with a short timeout, swallowing errors. + + + **Why inline (not a reference to the bundled `on_stop.sh`)?** When hooks run as + a **plugin**, they execute with the working directory set to the agent's + workspace (not the plugin directory), and there is no plugin-root path + variable — so a relative path like `hooks/on_stop.sh` won't resolve. + [`on_stop.sh`](https://github.com/OpenHands/enterprise-cookbook/blob/main/finish-callback/oh-finish-callback/hooks/on_stop.sh) is kept as the readable, + locally-testable **source of truth**; the identical script is embedded inline + in `hooks.json`, which is the copy that actually runs. If you edit the script, + re-embed it: + + ```bash + python - <<'PY' + import json + s = open("oh-finish-callback/hooks/on_stop.sh").read() + cfg = {"hooks": {"Stop": [{"matcher": "*", "hooks": [ + {"type": "command", "command": s, "timeout": 15, "async": True}]}]}} + json.dump(cfg, open("oh-finish-callback/hooks/hooks.json", "w"), indent=2) + open("oh-finish-callback/hooks/hooks.json", "a").write("\n") + PY + ``` + + +## Reliability: Callback + Polling + +The callback is a **latency optimization, not a delivery guarantee**. It won't +fire if: + +- the sandbox dies or the run errors out before reaching `FINISHED`, +- the receiver is down or the URL is unreachable, or +- the POST times out. + +So keep your polling loop as the **safety net** and treat the callback as the +fast path. That's exactly the hybrid the pattern is designed for: react +immediately when the callback arrives, fall back to polling when it doesn't. + +## Hook Types + +Hooks can intercept different lifecycle events: + +| Hook | When It Runs | Can Block? | Use Case | +| ---------------- | ---------------------------------- | -------------- | -------------------------------------- | +| PreToolUse | Before tool execution | ✅ Yes (exit 2) | Command validation | +| PostToolUse | After tool execution | ❌ No | Logging, metrics | +| UserPromptSubmit | Before processing user message | ✅ Yes | Content filtering | +| **Stop** | **When the agent tries to finish** | ✅ Yes | **Finish notification (this example)** | +| SessionStart | When a conversation starts | ❌ No | Setup, logging | +| SessionEnd | When a conversation ends | ❌ No | Cleanup | + +## Real-World Use Cases + +- **Windmill / workflow engines** — get pinged when a run finishes instead of polling every few seconds +- **CI pipelines** — kick off the next stage the moment the agent is done +- **Dashboards / queues** — mark a job complete in real time +- **Chat notifications** — post "run finished" to Slack/Teams from your own backend + +## Related + + + + full hook documentation + + + + how plugins work + + + + load this plugin (and pass secrets) via the REST API + + + + the PreToolUse example this one is modeled on + + + + turn a plugin into a no-code launch link + + + + attach metadata (like an external URL) to a conversation + + diff --git a/cookbook/gpg-commit-signing.mdx b/cookbook/gpg-commit-signing.mdx new file mode 100644 index 000000000..9098d5d81 --- /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@main (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/main/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/main/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/main/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`](/cookbook/workspace-isolation) example.) + +## Try it + +### Option 1: Load via API + +Use the companion [`load-plugin`](/cookbook/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`](/cookbook/command-whitelist), [`workspace-isolation`](/cookbook/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`](/cookbook/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`](/cookbook/command-whitelist) / [`workspace-isolation`](/cookbook/workspace-isolation) — other hook examples diff --git a/cookbook/index.mdx b/cookbook/index.mdx index 7a062399b..905037cd4 100644 --- a/cookbook/index.mdx +++ b/cookbook/index.mdx @@ -10,14 +10,118 @@ 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. - + + Retrieve cost and token usage metrics for conversations via CLI tool. + + + Attach key-value metadata to a conversation with tags and read it back from AppConversation.tags. + + + Notify an external URL when a conversation finishes using a Stop hook. + + + + React to conversation execution_status changes over WebSocket instead of polling. + + + + Poll the idle_time endpoint to detect when a workspace has gone quiet. + + + + Detect confirmed terminal execution_status over WebSocket with proper event handling. + + + +## 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. + + + +## Plugins, skills & MCP + +Extend conversations with plugins, skills, and MCP servers. + + + + Build a no-code /launch link, HTML button, or README badge that loads a plugin. + + + + Start a conversation with a plugin pre-loaded via the REST API. + + + + Validate MCP server configs against a sandbox agent-server via POST /api/mcp/test. + + + + Upload a local agent-skills directory into a sandbox, then start a conversation that uses them. + + + +## Custom agents & tools + +Configure the agent and add custom tools. + + + + Configure agent tools via the agent-server API to exclude the browser tool. + + + + Load a custom tool from a published pip package via pip install --target and tool_module_qualnames. + + + + Add custom server-side tools via source file upload and tool_module_qualnames. + + + + Override the default OpenHands system prompt with a custom one for specialized agents. + + + + Persist an account-level deny-list of skills so every conversation starts with them disabled. + ## Guardrails @@ -25,7 +129,15 @@ Observe conversations and react to their state. 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. + + + Allow only approved shell commands with PreToolUse hooks (whitelist approach for strict security). + + + + Enforce directory boundaries with hooks to prevent agents from navigating or writing outside assigned workspace. + diff --git a/cookbook/launch-plugin-badge.mdx b/cookbook/launch-plugin-badge.mdx new file mode 100644 index 000000000..f8a72d1b1 --- /dev/null +++ b/cookbook/launch-plugin-badge.mdx @@ -0,0 +1,252 @@ +--- +title: Launch Plugin Badge +description: Build a no-code /launch link, HTML button, or README badge that loads a plugin. +icon: rocket +--- + +{/* GENERATED from OpenHands/enterprise-cookbook@main (launch-plugin-badge/README.md). Edit the source, not this file. */} + + + +This example builds on [`load-plugin`](/cookbook/load-plugin). That one calls the API +with your key to start a conversation with a plugin loaded. Here we make a +**no-code launch link** anyone can click — perfect for an HTML ` +``` + +Markdown badge: + +```markdown +[![Tell a dad joke](https://img.shields.io/badge/Tell%20a%20dad%20joke-blue)](https://app.all-hands.dev/launch?plugins=W3sic291cmNlIjogImdpdGh1YjpPcGVuSGFuZHMvZW50ZXJwcmlzZS1jb29rYm9vayIsICJyZWYiOiAibWFpbiIsICJyZXBvX3BhdGgiOiAibGF1bmNoLXBsdWdpbi1iYWRnZS9kYWQtam9rZSIsICJwYXJhbWV0ZXJzIjogeyJhbmltYWwiOiAiZHVjayJ9fV0%3D&message=%2Fdad-joke%3Aabout) +``` + +### 2. Just load the plugin — user prompts after + +Omit `message`. The conversation starts with the plugin's skills loaded but +**no first action**, so the user types their own prompt. The `dad-joke` skill is +keyword-triggered: when the user asks for a joke, it asks for their favorite +animal and then delivers. + +```python +build_launch_url( + plugins=[{ + "source": "github:OpenHands/enterprise-cookbook", + "ref": "main", + "repo_path": "launch-plugin-badge/dad-joke", + }], + # no message -> agent waits for the user +) +``` + +Markdown badge: + +```markdown +[![Open with dad-joke loaded](https://img.shields.io/badge/Open%20with%20dad--joke%20loaded-blue)](https://app.all-hands.dev/launch?plugins=W3sic291cmNlIjogImdpdGh1YjpPcGVuSGFuZHMvZW50ZXJwcmlzZS1jb29rYm9vayIsICJyZWYiOiAibWFpbiIsICJyZXBvX3BhdGgiOiAibGF1bmNoLXBsdWdpbi1iYWRnZS9kYWQtam9rZSJ9XQ%3D%3D) +``` + +| | Variant 1 (entry command) | Variant 2 (load only) | +| --------------------- | ------------------------------ | ----------------------------------- | +| `message` query param | `/dad-joke:about` | *(omitted)* | +| On launch | Runs the skill immediately | Waits for the user's prompt | +| Best for | One-click "do the thing" demos | "Open a workspace with X available" | + +## Use the Functions in Your Own Tooling + +`build_launch_url.py` is importable: + +```python +from build_launch_url import build_launch_url, html_button, markdown_badge, plugin_spec + +url = build_launch_url( + [plugin_spec("github:owner/repo", "plugins/my-plugin", parameters={"x": "1"})], + message="/my-plugin:start", +) +print(markdown_badge("Try my plugin", url)) +print(html_button("Try my plugin", url)) +``` + +## Related + + + + the programmatic equivalent (the API call this link ultimately triggers). + + + + official docs for the /launch route. + + + + the plugin directory: a browseable catalog (served at /plugins, with a /api/plugins API) that builds launch links like these from a marketplace source repo. This example is what that directory does, by hand. + + + + what plugins are and the format they follow. + + + + the full marketplace → directory → frontend → app server → SDK journey. + + diff --git a/cookbook/load-plugin.mdx b/cookbook/load-plugin.mdx new file mode 100644 index 000000000..c111c1135 --- /dev/null +++ b/cookbook/load-plugin.mdx @@ -0,0 +1,217 @@ +--- +title: Load Plugin +description: Start a conversation with a plugin pre-loaded via the REST API. +icon: plug +--- + +{/* GENERATED from OpenHands/enterprise-cookbook@main (load-plugin/README.md). Edit the source, not this file. */} + + + +The smallest useful recipe for starting an OpenHands Cloud conversation that has +a **plugin pre-loaded**, using only the V1 App Server REST API. One field — +`plugins` — does the work. + +This example is **self-contained**: it ships its own plugin in +[`dad-joke/`](https://github.com/OpenHands/enterprise-cookbook/tree/main/load-plugin/dad-joke) and loads it straight from this repo on GitHub. No +external marketplace, no API keys beyond your OpenHands key. + + + A plugin is a small git-hosted bundle of slash commands, skills, hooks, and/or + MCP servers (Claude Code "plugin marketplace" format). `dad-joke` ships one of + each kind we need: a `/dad-joke:about` slash command that tells a dad joke + about an animal, and a keyword-triggered skill that asks for your favorite + animal first. + + +Want a **clickable link / README badge** instead of code? See the companion +example [`launch-plugin-badge`](/cookbook/launch-plugin-badge), which builds on this +one. + +## How It Works + +`POST /api/v1/app-conversations` is **asynchronous**. It returns a *start task*, +not a finished conversation. The script polls +`GET /api/v1/app-conversations/start-tasks?ids=` until the task yields +an `app_conversation_id`, then prints the conversation URL. + +(Omitting `sandbox_id` from the request lets the server provision a fresh +sandbox. To attach to a sandbox you prepared yourself, pass its id — see +[`clone-and-attach`](/cookbook/clone-and-attach).) + +## The One Field That Matters + +```python +requests.post( + f"{base_url}/api/v1/app-conversations", + headers={"Authorization": f"Bearer {api_key}"}, + json={ + "plugins": [ + { + "source": "github:OpenHands/enterprise-cookbook", + "ref": "main", + "repo_path": "load-plugin/dad-joke", + } + ], + "initial_message": { + "role": "user", + "content": [{"type": "text", "text": "/dad-joke:about duck"}], + }, + }, +) +``` + +A plugin spec has three parts: + +| Field | Meaning | Example | +| ----------- | -------------------------------------- | -------------------------------------- | +| `source` | Where the plugin lives | `github:OpenHands/enterprise-cookbook` | +| `ref` | Git ref/branch/tag | `main` | +| `repo_path` | Plugin sub-directory within the source | `load-plugin/dad-joke` | + + + The plugin is fetched from the **`ref`** you name. While iterating on a + branch, pass `--ref your-branch` so the fetch finds your copy; it resolves to + `main` once merged. + + +## Two Ways to Drive It + +The `initial_message` decides what happens once the plugin is loaded: + +1. **Run a skill immediately via an entry command.** Send the plugin's slash + command as the first message. The SDK registers `/dad-joke:about` as a + keyword trigger, so this tells a joke right away: + + ```bash + python load_plugin.py --message "/dad-joke:about duck" + ``` + +2. **Load the plugin, then prompt normally.** Send a natural-language message; + the plugin's skills are available for the agent to use when relevant. The + bundled skill fires on "dad joke", asks for your favorite animal, then + delivers: + + ```bash + python load_plugin.py --message "Tell me a dad joke" + ``` + +## Run It + +```bash +pip install requests +export OH_API_KEY="sk-oh-..." # Cloud API key + +python load_plugin.py # dad-joke + "/dad-joke:about duck" +``` + +### Options + +| Flag | Env var | Default | +| ---------------- | ------------------ | -------------------------------------------- | +| `--api-key` | `OH_API_KEY` | – (required) | +| `--base-url` | `OH_API_BASE` | `https://app.all-hands.dev` | +| `--source` | `PLUGIN_SOURCE` | `github:OpenHands/enterprise-cookbook` | +| `--ref` | `PLUGIN_REF` | `main` | +| `--repo-path` | `PLUGIN_REPO_PATH` | `load-plugin/dad-joke` | +| `--message` | `INITIAL_MESSAGE` | `/dad-joke:about duck` | +| `--secret` | – | *(none)* — repeatable `KEY=VALUE`; see below | +| `--poll-timeout` | `POLL_TIMEOUT` | `240` | + +## Loading a Private Plugin + +The bundled plugin is public, but `source` also accepts a **full Git URL**, and a +`${VAR}` placeholder in the `source` (or `ref`) is expanded against the +conversation's secrets **just before** the repo is cloned — so you can fetch a +private plugin without hard-coding a token. + + + **Version requirement.** Secret expansion in the plugin source landed in + [software-agent-sdk#3758](https://github.com/OpenHands/software-agent-sdk/pull/3758) + and ships in the **agent-server runtime + [`v1.29.0`](https://github.com/OpenHands/software-agent-sdk/releases/tag/v1.29.0)** + (released 2026-06-18). A conversation only gets the fix when its sandbox runs + `agent-server:1.29.0-python` or newer. + + In **OpenHands Enterprise** (tracked by the Replicated `openhands` chart + version), `0.7.65` is the first release to bundle that image — the VM-based + release being cut for it. Not yet on the Stable channel or OpenHands Cloud + (app.all-hands.dev) as of this writing. + + On older builds the `${VAR}` reaches `git clone` literally and a private + source fails at conversation start; a public `source` is unaffected. + + +There are four ways to supply the credential — all reference it **by name** in +the URL, so the raw token never has to appear in the `source`: + +| # | Where the token comes from | How you reference it | +| - | ------------------------------------ | -------------------------------------------------------------------------------------------- | +| 1 | Hard-coded literal token | the raw token in the URL (least desirable) | +| 2 | A user-profile custom secret | `${MY_TOKEN}` | +| 3 | A secret passed to **this** API call | send `secrets: {MY_TOKEN: …}`, reference `${MY_TOKEN}` | +| 4 | An OpenHands-managed provider token | `${GITHUB_TOKEN}`, `${GITLAB_TOKEN}`, `${BITBUCKET_TOKEN}`, `${BITBUCKET_DATA_CENTER_TOKEN}` | + +This script demonstrates **scenario 3** with `--secret` (which adds a `secrets` +field to the request): + +```bash +python load_plugin.py \ + --source 'https://x-access-token:${GIT_TOKEN}@github.com/me/private-plugins.git' \ + --repo-path plugins/my-plugin \ + --message '/my-plugin:start' \ + --secret GIT_TOKEN="$GIT_TOKEN" +``` + +Scenario **4** needs no `--secret` at all: if you've connected GitHub/GitLab/ +Bitbucket to OpenHands, just reference e.g. `${GITHUB_TOKEN}` and the managed +token is injected for you. + +Good to know: + +- **Braced `${VAR}` only** — a literal `$` in a token is never mangled. +- **Secrets only, not host env** — host environment variables are never folded + into the URL (that would be a credential-exfiltration vector). +- **Missing secret → left untouched** — the placeholder stays verbatim, so you + get a clear clone failure rather than a surprising default. +- **Stays redacted** — the persisted plugin spec keeps the `${VAR}` placeholder, + not the secret value. +- **HTTPS, not `ssh://`** — the credential travels inside the URL; SSH + authenticates out-of-band (a key), so there is no placeholder to expand. + +## The Bundled Plugin + +```text +dad-joke/ +├── .claude-plugin/ +│ └── plugin.json # manifest: name, entry_command, parameters +├── commands/ +│ └── about.md # the /dad-joke:about slash command +└── skills/ + └── dad-joke/ + └── SKILL.md # keyword skill: asks your favorite animal first +``` + +## Related + + + + turn this into a no-code launch link, HTML button, or README badge. + + + + the plugin directory: a browseable catalog of plugins (served at /plugins) loaded from a marketplace source repo. + + + + what plugins are and the format they follow. + + + + the full marketplace → frontend → app server → SDK journey. + + + + (SDK v1.29.0) - the secret expansion behind "Loading a private plugin". + + diff --git a/cookbook/per-conversation-secrets.mdx b/cookbook/per-conversation-secrets.mdx new file mode 100644 index 000000000..8f6afb0b4 --- /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@main (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/react-to-state-websocket.mdx b/cookbook/react-to-state-websocket.mdx new file mode 100644 index 000000000..8023b5567 --- /dev/null +++ b/cookbook/react-to-state-websocket.mdx @@ -0,0 +1,279 @@ +--- +title: React to State WebSocket +description: React to conversation execution_status changes over WebSocket instead of polling. +icon: bolt +--- + +{/* GENERATED from OpenHands/enterprise-cookbook@main (react-to-state-websocket/README.md). Edit the source, not this file. */} + + + +Two short scripts that react to OpenHands conversation state changes **as they +happen**, instead of polling a REST endpoint in a loop. Each starts a sandbox, +gets a conversation running on the sandbox's agent-server, then subscribes to +that conversation's event stream over a WebSocket and prints every +`execution_status` transition (`running` → `finished`/`error`) the moment the +agent-server emits it. + +This is the event-driven counterpart to the polling loops in +[`start-sandbox`](/cookbook/start-sandbox) and [`clone-and-attach`](/cookbook/clone-and-attach), +which repeatedly `GET` the status until it changes. Here nothing polls the +*conversation's execution state* — the script blocks on the socket and is woken +by the server. + +## How It Works + +```mermaid +flowchart TD + A["POST /api/v1/sandboxes
GET …/sandboxes?id=#lt;id#gt; (until RUNNING)"] + subgraph ATT["watch_attach.py"] + A1["POST /api/v1/app-conversations (attach)"] --> A2["GET …/start-tasks?ids=#lt;id#gt; (poll for id)"] + end + subgraph DIR["watch_direct.py"] + D1["POST {agent}/api/conversations (id returned now)"] + end + A --> A1 + A --> D1 + A2 --> W["WS {agent}/sockets/events/#lt;id#gt;?resend_mode=all
# react to states (shared)"] + D1 --> W + W --> X["DELETE /api/v1/sandboxes/#lt;id#gt;?sandbox_id=#lt;id#gt;
# clean up"] +``` + +### Why the WebSocket instead of polling? + +The agent-server emits a `ConversationStateUpdateEvent` with +`key == "execution_status"` on every transition. Subscribing means you learn +about `finished` (or `error`/`stuck`) the instant it happens, with no request +spam and no latency floor set by your poll interval. The socket also carries the +other events (`MessageEvent`, `ActionEvent`, `StreamingDeltaEvent`, …) if you +want to follow the agent's work in real time. + +## Two Ways to Get the Conversation Running + +Both scripts watch state identically over the same WebSocket. They differ only +in **how the conversation is created**, and that choice comes with a genuine +trade-off: + +| Script | Creates the conversation via | LLM credentials | Start-task poll | +| ------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| [`watch_attach.py`](https://github.com/OpenHands/enterprise-cookbook/blob/main/react-to-state-websocket/watch_attach.py) | **Cloud** `POST /api/v1/app-conversations` (attach to sandbox) | **Not needed** — the Cloud layer injects your account's LLM | **Required** — the call is async and returns a *start task*, so you poll `start-tasks` for the conversation id | +| [`watch_direct.py`](https://github.com/OpenHands/enterprise-cookbook/blob/main/react-to-state-websocket/watch_direct.py) | **Agent-server** `POST /api/conversations` | **Required** — a direct conversation inherits no Cloud LLM creds, so you pass `--llm-model` / `--llm-api-key` | **Not needed** — the id is returned synchronously | + +In one sentence: **if you want to avoid passing an LLM key, you accept a brief +start-task poll; if you want to avoid the poll, you accept passing an LLM key.** +Neither poll is on the *conversation's execution state* — that is always +event-driven. `watch_attach.py`'s poll is provisioning latency (waiting for the +sandbox to prepare skills/repo and start the conversation); once it hands back an +id, the socket takes over. + + + Both approaches attach to a sandbox first, exactly like + [`clone-and-attach`](/cookbook/clone-and-attach) — the WebSocket lives on the + sandbox's agent-server regardless of which endpoint created the conversation. + + +## What They Print + +`watch_attach.py` (note the short start-task poll, then the socket): + +```text +sandbox: 6MTcHjsITPtO09W0yYLHSd + sandbox status: RUNNING +agent: https://zztaextwktekrdgh.prod-runtime.all-hands.dev + +=== attaching conversation (start-task poll) === + start-task status: SETTING_UP_SKILLS + start-task status: STARTING_CONVERSATION + start-task status: READY +conversation: b04bc6236d8745d7a83b2e2d3714f1a9 + +=== watching conversation state (WebSocket) === + .. SystemPromptEvent + .. MessageEvent + >> execution_status: running + .. StreamingDeltaEvent + .. MessageEvent + >> execution_status: finished +=== done === + +Cleaning up sandbox… +``` + +`watch_direct.py` (no start-task poll; id is immediate): + +```text +sandbox: 1ChU3JfCyeKY2hprWBIQ8n + sandbox status: RUNNING +agent: https://aurgryilvhwboigr.prod-runtime.all-hands.dev +conversation: 1239717a-0396-435b-bfb8-f0b857891fd7 + +=== watching conversation state (WebSocket) === + .. SystemPromptEvent + .. MessageEvent + >> execution_status: running + .. MessageEvent + >> execution_status: finished +=== done === + +Cleaning up sandbox… +``` + +## Run It + +```bash +export OH_API_KEY=... # your https://app.all-hands.dev API key +pip install requests websockets +``` + +**No LLM key (Cloud attach):** + +```bash +python watch_attach.py --message "List the files in /workspace/project" +``` + +**No start-task poll (agent-server direct):** a conversation created directly on +the agent-server does not inherit Cloud LLM credentials, so pass your own: + +```bash +export LLM_API_KEY=... +python watch_direct.py --llm-model "gpt-4o-mini" --message "Say hello, then stop." +``` + +The default `gpt-4o-mini` expects a real OpenAI key. If you route through a proxy +(e.g. a LiteLLM endpoint), also pass `--llm-base-url` and a model name your proxy +recognizes: + +```bash +export LLM_API_KEY=... # your proxy key +python watch_direct.py \ + --llm-model "litellm_proxy/anthropic/claude-haiku-4-5" \ + --llm-base-url "https://your-proxy.example.com" \ + --message "Say hello, then stop." +``` + +### Flags + +Shared by both scripts: + +| 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) | +| `--message` | — | `Say hello, then stop.` | +| `--poll-timeout` | `POLL_TIMEOUT` | `180` (seconds) | +| `--keep` | — | off (deletes the sandbox at the end) | + +`watch_direct.py` also takes: + +| Flag | Env var | Default | +| ---------------- | -------------- | ------------- | +| `--llm-model` | `LLM_MODEL` | `gpt-4o-mini` | +| `--llm-api-key` | `LLM_API_KEY` | — (required) | +| `--llm-base-url` | `LLM_BASE_URL` | none | + +By default the sandbox is deleted when the script exits. Pass `--keep` to leave +it running and print the `DELETE` command instead. + +## Another Option: React from *Inside* the Sandbox with Hooks + +The WebSocket approach is for a client that holds a connection open and reacts +**externally**. If instead you want the sandbox itself to act on a lifecycle +event — notify a URL, run setup, gate an action — you can use **lifecycle +hooks** delivered by a plugin. Hooks run inside the agent-server on events like +`SessionStart`, `PreToolUse`, `PostToolUse`, and `Stop`, so there is nothing to +poll and no socket to keep open. + +Two examples in this repo show the pattern: + +- [`finish-callback`](/cookbook/finish-callback) — a **`Stop` hook** that `POST`s to a + URL you control the moment a conversation finishes. This is the closest + analogue to "tell me when it's done" without a held-open socket: the sandbox + pushes to you. (It also documents the callback-plus-polling hybrid for + reliability.) +- [`gpg-commit-signing`](/cookbook/gpg-commit-signing) — a **`SessionStart` hook** that + configures the environment at the start of *every* conversation, illustrating + the "run something on a lifecycle event" shape more generally. + +Rule of thumb: use the **WebSocket** (this example) when an external client +needs to follow state live; use **hooks** (a plugin) when the reaction should +happen inside the sandbox on a specific lifecycle event. See the +[OpenHands Hooks Guide](/sdk/guides/hooks) for the +full list of hook types and their blocking semantics. + +## Notes + +- **Only the sandbox (and, for `watch_attach.py`, the start task) is polled, and + only for provisioning.** Waiting for `RUNNING` / `READY` is lifecycle, not + conversation execution state. Once the agent-server is up and the id is known, + the conversation is entirely event-driven. +- **First-frame auth** keeps the session key out of the URL (and therefore out + of reverse-proxy / load-balancer access logs). A deprecated + `?session_api_key=…` query parameter also works but is not recommended. +- **`resend_mode=all`** makes the subscription race-free: because an + `initial_message` starts the run at create time, some events may already exist + before the socket connects. Replaying them means the example never misses the + first `running`. Use `resend_mode=since&after_timestamp=…` if you have already + fetched history over REST and only want newer events. +- **Terminal states.** The scripts stop at `finished`, `error`, or `stuck`. The + full set of `ConversationExecutionStatus` values is `idle`, `running`, + `paused`, `waiting_for_confirmation`, `finished`, `error`, `stuck`, + `deleting`. +- **Going deeper — is it *really* done?** These scripts read the per-field + `execution_status` and stop on the first `finished`, which is perfect for + reacting to transitions. If you specifically need to detect a **confirmed + terminal state** (a per-field `finished` is advisory — a Stop hook can revert + it), see [`watch-terminal-state`](/cookbook/watch-terminal-state). It builds on this + example's Cloud approach and adds handling for both + `ConversationStateUpdateEvent` shapes (per-field and `full_state`), plus + log-safe first-message auth. +- **Webhooks (reacting from an external service, no held-open socket).** The + agent-server can also *push* to an HTTP endpoint you host — + `POST {base_url}/conversations` on start/pause/resume/stop and + `POST {base_url}/events/{id}` for event batches. That is a good fit when an + external service, not a long-lived client, needs to react. However, those + webhooks are supplied via the agent-server's startup config or + `POST /api/init`, and **OpenHands Cloud sandboxes run with + `deferred_init=False`** — so there is no way to inject webhook config. + `GET /api/init` makes this explicit, returning `404` with the message + `server is not running with deferred_init=True; the /api/init endpoint is not + available` (a `POST` is rejected at the auth layer first with `401`). So + agent-server webhooks require an agent-server you run + yourself (e.g. via Docker). On Cloud, use the WebSocket here, or the in-sandbox + **`Stop` hook** in [`finish-callback`](/cookbook/finish-callback) for a push-style + finish notification. + +## 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 + - `POST /api/v1/app-conversations` — **attach** a conversation to a sandbox + (`watch_attach.py` only); returns a start task + - `GET /api/v1/app-conversations/start-tasks?ids=` — poll for the + `app_conversation_id` (`watch_attach.py` only) + - `DELETE /api/v1/sandboxes/{id}?sandbox_id=` — clean up + +### 2. Agent Server — runs inside the sandbox + +- Base URL: the entry in `sandbox.exposed_urls` with `name == "AGENT_SERVER"`. +- Auth header: `X-Session-API-Key: ` returned by the + sandbox-create call (different from your Cloud API key). +- Endpoints: + - `POST /api/conversations` — create a conversation directly + (`watch_direct.py` only). Body requires a `workspace` and an `agent` (with + an `llm`); an `initial_message` makes it start running immediately. + - `GET /sockets/events/{conversation_id}` — **WebSocket** event stream. This + is the shared star of both scripts. Authenticate by sending + `{"type": "auth", "session_api_key": "…"}` as the first frame. Add + `?resend_mode=all` to replay events already produced since the conversation + started (this closes the create→connect race without polling). + + + Full Agent Server schema is at `/openapi.json` once the + sandbox is `RUNNING`. + diff --git a/cookbook/server-info-idle.mdx b/cookbook/server-info-idle.mdx new file mode 100644 index 000000000..1bd6520b1 --- /dev/null +++ b/cookbook/server-info-idle.mdx @@ -0,0 +1,199 @@ +--- +title: Server Info Idle +description: Poll the idle_time endpoint to detect when a workspace has gone quiet. +icon: clock +--- + +{/* GENERATED from OpenHands/enterprise-cookbook@main (server-info-idle/README.md). Edit the source, not this file. */} + + + +Detect when an agent has gone quiet by polling the agent-server's built-in idle +timer: `GET /server_info` reports `idle_time`, the seconds since the last +activity on the server. This is the **exact signal `runtime-api` polls** to +decide when a sandbox is idle enough to pause/reap — this example uses the same +signal for your own "has the workspace gone quiet?" check. + +On **Cloud** (the default here), the same response also carries +`runtime_idle_timeout_seconds`, the platform's real reap threshold, so you can +see `idle_time` climbing toward the very number the platform acts on. + +One file: + +- [`idle_poll.py`](https://github.com/OpenHands/enterprise-cookbook/blob/main/server-info-idle/idle_poll.py) — start a Cloud sandbox, attach a conversation + (no LLM key needed), then poll `/server_info` until `idle_time` crosses a + threshold and declare the agent idle. Pass `--local` to run against an + agent-server you start in Docker instead. + +## How It Works + +```mermaid +sequenceDiagram + participant P as idle_poll.py + participant S as Cloud app server / sandbox agent-server + P->>S: POST /api/v1/sandboxes + P->>S: GET /api/v1/sandboxes?id (until RUNNING) + P->>S: GET #lt;agent#gt;/server_info (baseline) + Note right of S: idle_time, runtime_idle_timeout_seconds + P->>S: POST /api/v1/app-conversations + Note right of S: attach (no LLM key)#59; poll start-task + P->>S: GET #lt;agent#gt;/server_info (loop) + Note right of S: idle_time climbing... + Note over P: ...until idle_time > threshold -> #quot;agent idle#quot; + P->>S: DELETE /api/v1/sandboxes/{id} + Note right of S: clean up +``` + +## `idle_time` vs. `execution_status` + +They answer different questions — pick per your need: + +| | `idle_time` (this example) | `execution_status` | +| ------------------------------------------- | --------------------------------- | ---------------------------------------------------- | +| **Question** | "Has the *workspace* gone quiet?" | "Is *this conversation* done?" | +| **Granularity** | Server-wide heartbeat | Per-conversation state machine | +| **Distinguishes finished / error / stuck?** | No | Yes (`is_terminal()`) | +| **How to consume** | Poll `GET /server_info` | Push via [WebSocket](/cookbook/watch-terminal-state) | +| **Used by the platform for** | Reaping idle sandboxes | Reporting run completion | + +Use `idle_time` when you just want "nothing is happening anymore" without +subscribing to a conversation; use `execution_status` when you need an +authoritative terminal signal. + +## Run It + +```bash +export OH_API_KEY=... # your https://app.all-hands.dev API key +pip install requests + +python idle_poll.py +``` + +No LLM key is required: attaching through the Cloud app server injects your +account's configured LLM. + +### Flags + +| 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) | +| `--message` | — | `Say hello in one short sentence, then stop.` | +| `--poll-timeout` | `POLL_TIMEOUT` | `180` (sandbox / start-task) | +| `--idle-threshold` | — | `15` (seconds) | +| `--poll-interval` | — | `3` (seconds) | +| `--watch-timeout` | — | `180` | +| `--keep` | — | off (deletes the sandbox at the end) | + +## What It Prints + +```text +sandbox: 2UqRNuFbMFhgLLntVnla5k + sandbox status: RUNNING +agent: https://tsjascgdpnrkidek.prod-runtime.all-hands.dev +baseline /server_info: idle_time=101.0s runtime_idle_timeout_seconds=1200.0 + +=== attaching conversation (start-task poll) === + start-task status: SETTING_UP_SKILLS + start-task status: STARTING_CONVERSATION + start-task status: READY +conversation: be8a7d2965f34f85b142e3ee44ed188b + +polling /server_info.idle_time every 3.0s; declaring idle at > 15.0s + + idle_time= 0.0s uptime=134.0s + idle_time= 3.0s uptime=137.0s + idle_time= 1.0s uptime=140.0s # drops when the agent acts + idle_time= 4.0s uptime=143.0s + idle_time= 7.0s uptime=146.0s + idle_time= 10.0s uptime=149.0s + idle_time= 13.0s uptime=152.0s + idle_time= 16.0s uptime=155.0s # climbs after the agent stops + +agent idle: idle_time exceeded 15.0s — the workspace has gone quiet. +(this is the same signal runtime-api uses to reap sandboxes; for a +per-conversation terminal state use execution_status) + +Cleaning up sandbox… +``` + +## Running Locally Without Cloud + +The audience for this example is **Cloud**. If you have no Cloud account, pass +`--local` to start an agent-server in Docker and poll it directly: + +```bash +docker must be running +pip install requests + +export LLM_API_KEY=... # required in --local mode +export LLM_MODEL=litellm_proxy/... # required in --local mode +export LLM_BASE_URL=https://... # optional (provider default if unset) + +python idle_poll.py --local +``` + +The only differences in `--local` mode: the script `docker run`s the +`ghcr.io/openhands/agent-server:latest-python` image, creates the conversation +directly on the agent-server (`POST /api/conversations`, which needs an LLM key), +and reads `/server_info` at `http://localhost:8000`. Note that +`runtime_idle_timeout_seconds` is **`null`** locally — there is no platform +reaper — so only the `idle_time` heartbeat is meaningful. Local-only flags: +`--llm-api-key`, `--llm-model`, `--llm-base-url`, `--session-key`, `--image`, +`--server-port`, `--container-name`. + +## Notes + +- **Coarse by design.** `idle_time` cannot tell you *why* things went quiet + (finished vs. errored vs. stuck vs. simply waiting). It is a heartbeat, not a + state machine. That is exactly why the platform uses it for reaping and not for + reporting completion. +- **Threshold choice.** Set `--idle-threshold` well above your longest expected + gap between agent actions (LLM latency, long tool calls), or you will declare + "idle" mid-run. The platform's default (`runtime_idle_timeout_seconds`, \~1200s + on Cloud) is deliberately large for this reason. +- Full agent-server schema: `/openapi.json`. + +## APIs Used + +### Cloud app server — manages the sandbox lifecycle + +- Base URL: `https://app.all-hands.dev`, auth header `X-Session-API-Key: `. +- `POST /api/v1/sandboxes` — start a sandbox +- `GET /api/v1/sandboxes?id=` — poll until `RUNNING` +- `POST /api/v1/app-conversations` — attach a conversation (returns a start task) +- `GET /api/v1/app-conversations/start-tasks?ids=` — poll for the id +- `DELETE /api/v1/sandboxes/{id}?sandbox_id=` — clean up + +### Agent server — `GET /server_info` + +Read from the sandbox's `AGENT_SERVER` exposed URL with its `session_api_key`. +Returns a `ServerInfo` object; the fields this example reads: + +| Field | Meaning | +| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `idle_time` | Seconds since the last activity (file ops, agent steps, ACP heartbeat). Drops while the agent works, climbs once it stops. | +| `uptime` | Seconds since the server started. | +| `runtime_idle_timeout_seconds` | The platform's own reap threshold — how long `runtime-api` lets a sandbox sit idle before pausing/stopping it (e.g. `1200.0` on Cloud). **Populated on Cloud; `null` on a plain local agent-server, which has no reaper.** | + +On Cloud, `runtime-api` reaps a sandbox roughly when +`idle_time >= runtime_idle_timeout_seconds`. This demo uses a much smaller +threshold (`--idle-threshold`, default 15s) so you can watch idle detection fire +quickly against the same `idle_time` signal. + +## Related + + + + authoritative per-conversation terminal state over the WebSocket (push) + + + + react to every execution_status transition over the WebSocket + + + + the sandbox lifecycle this example builds on + + diff --git a/cookbook/service-account-github-pat.mdx b/cookbook/service-account-github-pat.mdx new file mode 100644 index 000000000..22eb20a65 --- /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@main (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..deb165e02 --- /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@main (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/main/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/main/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/cookbook/test-mcp-config.mdx b/cookbook/test-mcp-config.mdx new file mode 100644 index 000000000..262136507 --- /dev/null +++ b/cookbook/test-mcp-config.mdx @@ -0,0 +1,189 @@ +--- +title: Test MCP Config +description: Validate MCP server configs against a sandbox agent-server via POST /api/mcp/test. +icon: vial +--- + +{/* GENERATED from OpenHands/enterprise-cookbook@main (test-mcp-config/README.md). Edit the source, not this file. */} + + + +Validate **MCP server configurations** against a real sandbox's agent-server, +**before** wiring them into a conversation. + +## Why + +The OpenHands web UI lets you *add* an MCP server, but it does not currently +tell you whether the server actually connects. If the URL, token, or network +path is wrong, the server's tools simply never appear in a conversation — with +no error shown in the UI. (Under the hood, when multiple MCP servers are +configured, a server that fails to connect is logged as a warning in the +runtime/agent-server logs and silently skipped.) + +The agent-server already ships an endpoint that solves this: +`POST /api/mcp/test` connects to a single candidate server, lists its tools, +and optionally invokes one read-only tool to exercise credentials. This example +drives that endpoint from the Cloud API so you can verify a config end-to-end. + + + Requires an agent-server that includes `POST /api/mcp/test` + (added in **agent-server 1.29.0 / OpenHands 1.8.0**). Older runtimes return + `404` and the script tells you to upgrade the runtime image. + + +## How It Works + +```text +1. POST /api/v1/sandboxes -> start a sandbox (no conversation) +2. GET /api/v1/sandboxes?id= -> poll until status == RUNNING +3. read exposed_urls[AGENT_SERVER] -> the sandbox's agent-server URL + and session_api_key + per-sandbox session key +4. POST {agent_server}/api/mcp/test -> validate each server config +``` + +`POST /api/mcp/test` returns **HTTP 200 in both success and failure** — a failed +connection is the *expected* outcome of validating user input, not a server +error: + +```json +{"ok": true, "tools": ["..."], "tool_result": null} +{"ok": false, "error": "Client failed to connect: ...", "error_kind": "connection"} +``` + +`error_kind` is one of `timeout`, `connection`, or `unknown`. Note that HTTP +status failures (e.g. a `401` from a bad token) currently come back as +`unknown` with the status in the `error` text, so read `error` — not just +`error_kind` — when triaging auth problems. + +## Auth + +```bash +export OH_API_KEY=... # OpenHands Cloud API key +``` + +The same key authenticates the app-server calls (via `X-Session-API-Key`); the +agent-server calls use the per-sandbox `session_api_key` returned with the +sandbox. + +## Install + +Only depends on `requests`: + +```bash +pip install requests +# or, from the repo root: uv run --with requests test-mcp-config/test_mcp_config.py ... +``` + +## Run It + +```bash +# Single remote (streamable-http) server with a bearer token +python test_mcp_config.py \ + --url https://mcp.example.com/mcp --type shttp --server-api-key "$TOKEN" + +# Use an explicit header instead of --server-api-key, and verify credentials +# by invoking a known read-only tool (listing tools alone often won't): +python test_mcp_config.py --url https://mcp.example.com/mcp \ + --header "Authorization=Bearer $TOKEN" \ + --tool-call list_things + +# Stdio (subprocess) server. +# NOTE: values starting with "-" (e.g. npx's "-y") must use the --flag=value +# form, otherwise argparse treats them as options. This applies to +# --arg / --env / --header / --tool-arg. +python test_mcp_config.py --command npx --arg=-y --arg some-mcp-server + +# Test every server in an SDK-style config file +python test_mcp_config.py --config my_mcp_config.json + +# Test the servers actually saved in your account settings +python test_mcp_config.py --from-settings + +# Reuse a sandbox you already started (skips create + delete) +python test_mcp_config.py --sandbox-id --url https://mcp.example.com/mcp +``` + +### Testing your saved settings (and picking from multiple servers) + +`--from-settings` reads `agent_settings.mcp_config` from `GET /api/v1/settings` +(handling the stored `auth` / `transport` shape, including bearer tokens) and, +by default, **tests every server** you have configured. + +When several servers are installed you can see the options and target a subset: + +```bash +# List configured servers without starting a sandbox (no secrets printed) +python test_mcp_config.py --from-settings --list + +# Test only specific servers by name (repeatable) +python test_mcp_config.py --from-settings --server jira --server figma +``` + + + MCP config is a **single shared map** (`mcp_config.mcpServers`) — it is not + split across LLM/settings profiles, so "multiple installed" means multiple + servers in that one map. Use `--list` to discover names, then `--server` to + pick. Point `--settings-url` at an org/self-hosted settings endpoint if your + config lives somewhere other than `{base-url}/api/v1/settings`. + + +`--config` accepts an SDK-style file (the same `mcpServers` shape returned by +`GET /api/v1/settings` under `agent_settings.mcp_config`): + +```json +{ + "mcpServers": { + "jira": {"url": "https://mcp-jira.example.com/http", "transport": "http"}, + "figma": {"url": "https://mcp-figma.example.com/mcp", "transport": "http"}, + "local": {"command": "npx", "args": ["-y", "some-mcp-server"]} + } +} +``` + +By default the script creates a sandbox, runs the tests, and deletes the +sandbox. Pass `--keep` (or `--sandbox-id`) to leave it running. The process +exits non-zero if any server fails, so it is CI-friendly. + +## Example Output + +Running against three servers — the official MCP reference server +([`@modelcontextprotocol/server-everything`](https://github.com/modelcontextprotocol/servers/tree/main/src/everything), +stdio) plus two deliberate failures: + +```text +sandbox: 2092i4jTpja41kRuzNcLIJ + status: RUNNING +agent: https://.prod-runtime.all-hands.dev + +--- everything_reference (stdio: npx) --- + OK connected; 13 tool(s): echo, get-annotated-message, get-env, ... + +--- bad_dns (http: http://nonexistent.invalid/mcp) --- + FAIL [connection] Client failed to connect: [Errno -2] Name or service not known + +--- conn_refused (http: http://127.0.0.1:1/mcp) --- + FAIL [connection] Client failed to connect: All connection attempts failed + +1/3 server(s) OK. + +Deleting sandbox 2092i4jTpja41kRuzNcLIJ ... +``` + + + Tip: stdio servers fetched via `npx -y` download on first run, so give them a + longer `--timeout` (e.g. `--timeout 90`). + + +## Notes & Limitations + +- **Credentials checked only on tool invocation.** Some servers connect and + list tools fine with a bad token, and only fail when a tool runs. Pass + `--tool-call ` to exercise those credentials; the outcome is + reported under `tool_result` and does **not** change `ok`. +- **Plaintext secrets.** This example sends whatever token/headers you pass, in + plaintext, to the sandbox you control. (The web UI's "edit" flow round-trips + *encrypted* stored secrets to the same endpoint; that cross-cipher path is a + UI-internal detail and out of scope here.) +- **Self-hosted / Enterprise.** Point `--base-url` (or `OH_API_BASE`) at your + deployment. The flow is identical as long as the runtime image is new enough + to expose `POST /api/mcp/test`. diff --git a/cookbook/upload-skills.mdx b/cookbook/upload-skills.mdx new file mode 100644 index 000000000..d63bdbf8e --- /dev/null +++ b/cookbook/upload-skills.mdx @@ -0,0 +1,224 @@ +--- +title: Upload Skills +description: Upload a local agent-skills directory into a sandbox, then start a conversation that uses them. +icon: upload +--- + +{/* GENERATED from OpenHands/enterprise-cookbook@main (upload-skills/README.md). Edit the source, not this file. */} + + + +This example is a small command-line tool: you hand it a **local agent-skills +directory** and it provisions a sandbox, copies your skills into the right place +inside it, and starts a fresh conversation that can use them. It prints the URL +of the conversation it just created. + +It builds on [`clone-and-attach`](/cookbook/clone-and-attach), which prepares a sandbox +(clone + `setup.sh`) and then attaches a conversation. Here we prepare the +sandbox by **uploading skills** instead. Read [`start-sandbox`](/cookbook/start-sandbox) +first if the sandbox/agent-server split is new to you. + +## Why Would I Do This? + +You have skills authored locally (a folder of `SKILL.md` files) and you want an +agent to use them *without* committing them to a repo or installing them by +hand. This script drops them into the sandbox's **user skills directory** before +the conversation starts, so the brand-new conversation loads them automatically. + + + **Most of the time you don't need this script.** The standard ways to give a + workspace skills are to **store them in your repository as a plugin** (e.g. + under `.openhands/skills/`) and **start the conversation with that plugin + reference**, or to run **`/add-skill `** to add a skill to the + workspace. Reach for this example when you want to push *local, uncommitted* + skills straight into a sandbox programmatically — for automation, quick + experiments, or skills you're not ready to commit. + + +### Where do the skills go, and why there? + +When OpenHands starts a conversation it loads skills from several sources and +merges them. **User skills** come from the sandbox user's home: + +- `~/.openhands/skills/` ← this example's default target +- `~/.agents/skills/` ← newer AgentSkills location (use `--remote-skills-dir`) +- `~/.openhands/microagents/` (legacy) + +Because the upload happens **before** the conversation is created, the loader +picks the skills up for that conversation. (The start-task lifecycle even has a +dedicated `SETTING_UP_SKILLS` phase.) Putting them under the user home — rather +than a repo's `.openhands/skills/` — means they load regardless of whether a +repository is selected. + + + Already-running conversations are **not** re-scanned. Upload skills first, + then start the conversation — which is exactly what this script does. + + +## How It Works + +```text +POST /api/v1/sandboxes # 1. start a sandbox (no conversation) +GET /api/v1/sandboxes?id= # 2. poll until status == RUNNING +GET {agent}/api/file/home # 3. resolve ~ (sandbox user's home) +POST {agent}/api/file/upload?path= # 4a. upload one tar.gz of your skills +POST {agent}/api/bash/execute_bash_command # 4b. extract it into ~/.openhands/skills +POST /api/v1/app-conversations # 5. start a conversation (sandbox_id=) +GET /api/v1/app-conversations/start-tasks # 5b. poll for the app_conversation_id +``` + +Steps 1–2 and 5 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). The agent server is where file upload and shell execution live — the +Cloud app server has no file-upload route. + +### Recursive copy without per-file round-trips + +Rather than upload files one at a time, the script tars the **contents** of your +local directory (`arcname="."`) into a single `.tar.gz`, uploads that one +archive, and extracts it with `tar -xzf ... -C `. So a local layout +like: + +```text +my-skills/ +├── code-review/SKILL.md +└── deploy-helper/SKILL.md +``` + +ends up as `~/.openhands/skills/code-review/SKILL.md` and +`~/.openhands/skills/deploy-helper/SKILL.md` in the sandbox. + +## Run It + +```bash +export OH_API_KEY=... # your https://app.all-hands.dev API key +pip install requests + +# Zero-config: uploads the bundled example-skills/ and starts a conversation +# that asks the agent to list the skills it can see. +python upload_skills.py ./example-skills +``` + +Sample output: + +```text +local skills dir: /path/to/enterprise-cookbook/upload-skills/example-skills + AgentSkills (SKILL.md): 1 + - hello-openhands + loose .md skills: 0 +sandbox: 1ho9eZpt4m27CC23XPdGcN + sandbox status: RUNNING +agent: https://ahhygodzefollslv.prod-runtime.all-hands.dev + +=== upload skills -> /home/openhands/.openhands/skills === + uploading 412 bytes -> /tmp/oh-upload-skills.tar.gz +$ extract skills (exit=0) +installed skills: + hello-openhands/SKILL.md + +=== start conversation === + start-task status: SETTING_UP_SKILLS + start-task status: STARTING_CONVERSATION + start-task status: READY + +Conversation started on your skills-loaded sandbox: + https://app.all-hands.dev/conversations/f041a2e252cf45b39a46a3189b2efce7 +``` + +Open that URL and ask the agent to *"say hello"* — it should answer using the +uploaded `hello-openhands` skill. + +## Point It at Your Own Skills + +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 | +| ------------------------- | -------------------- | --------------------------- | ------------------------------------------------------------ | +| `skills_dir` (positional) | — | — (required) | Local skills directory to upload | +| `--api-key` | `OH_API_KEY` | — (required) | Cloud API key | +| `--base-url` | `OH_API_BASE` | `https://app.all-hands.dev` | Cloud app server | +| `--remote-skills-dir` | `REMOTE_SKILLS_DIR` | `~/.openhands/skills` | Destination in the sandbox (`~` expands to the sandbox home) | +| `--message` | `INITIAL_MESSAGE` | a "list your skills" prompt | First message to the agent | +| `--title` | `CONVERSATION_TITLE` | `upload-skills demo` | Conversation title | +| `--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 +# Upload your own folder of SKILL.md skills and target the newer location. +python upload_skills.py ~/my-skills \ + --remote-skills-dir '~/.agents/skills' \ + --message "Use my deploy-helper skill to outline a release plan." +``` + +### Reusing the sandbox so new conversations load these skills automatically + +Once this script runs, the uploaded skills live in the sandbox's home directory, +so the sandbox is now a ready-made, skills-loaded environment. Whether *new* +conversations reuse it — and therefore inherit those skills for free — is +controlled by the **Sandbox Grouping Strategy** setting under +**Settings → Application**. + +- **Default — `No Grouping (new sandbox per conversation)`:** every new + conversation gets its own fresh sandbox, so conversations you start later + (in the UI or via the API) will **not** see the skills uploaded here. +- **Any grouping strategy** (e.g. `Group by Newest`, `Add to Any`, + `Least Recently Used`, `Fewest Conversations`): new conversations are added to + an existing, still-running sandbox instead of a fresh one. Because every + conversation reloads skills from the sandbox home when it starts, the + conversations you create **straight from the web UI** land on this + skills-loaded sandbox and pick up the uploaded skills automatically — no + re-running this script needed. + +So a typical flow is: run this script once to seed a sandbox with your skills, +set a grouping strategy in the UI, then just open new conversations normally and +they'll already know your skills. + + + **Caveat: this only works while the seeded sandbox is still running.** A new + conversation can only join a sandbox that is still running. If the seeded + sandbox has gone **inactive** (paused after a stretch of no activity, but not + yet deleted), a new conversation starts a *fresh* sandbox instead — without + your skills. Reopening an **existing** conversation still brings its sandbox + back (and your skills with it), and you can always reuse a specific sandbox — + even an inactive one — by passing `--sandbox-id `, which wakes it + up. + + +(If you'd rather stay scripted, you can also target a specific sandbox directly +with `--sandbox-id ` / `SANDBOX_ID` instead of relying on the +grouping setting — this resumes the sandbox if it is paused.) + +### Skill format + +Each skill is a directory containing a `SKILL.md` with YAML frontmatter: + +```markdown +--- +name: my-skill +description: What it does and when to use it. +--- + +# My Skill + +Instructions for the agent... +``` + +Add an optional `triggers:` list to auto-activate a skill when keywords appear +in a user message. See the bundled [`example-skills/`](https://github.com/OpenHands/enterprise-cookbook/tree/main/upload-skills/example-skills) for a +working sample. + +## 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" +``` diff --git a/cookbook/watch-terminal-state.mdx b/cookbook/watch-terminal-state.mdx new file mode 100644 index 000000000..5e075eda5 --- /dev/null +++ b/cookbook/watch-terminal-state.mdx @@ -0,0 +1,192 @@ +--- +title: Watch Terminal State +description: Detect confirmed terminal execution_status over WebSocket with proper event handling. +icon: terminal +--- + +{/* GENERATED from OpenHands/enterprise-cookbook@main (watch-terminal-state/README.md). Edit the source, not this file. */} + + + +A follow-on to [`react-to-state-websocket`](/cookbook/react-to-state-websocket). That +example covers the basics — start a Cloud sandbox, **attach** a conversation (no +LLM key needed), open the agent-server WebSocket +(`/sockets/events/{conversation_id}`), and print every `execution_status` +transition. **Read it first.** + +This example keeps the *same Cloud approach* and layers on what you need to know +to answer one specific question reliably: **"is this conversation actually +done?"** — and then exit. It adds three things on top of the basics: + +1. **Both event shapes.** `ConversationStateUpdateEvent` arrives two ways; a + robust "am I done?" check reads both. +2. **`finished` is advisory; `error`/`stuck` are immediate.** A per-field + `finished` can be reverted by a Stop hook, so it is confirmed with a + `full_state` snapshot. +3. **First-message auth**, keeping the session key out of URLs and proxy logs. + +One file: + +- [`watch_terminal_state.py`](https://github.com/OpenHands/enterprise-cookbook/blob/main/watch-terminal-state/watch_terminal_state.py) — start a sandbox, + attach a conversation, watch the socket, and report the **confirmed** terminal + state. + +## Prerequisites + +Start with [`react-to-state-websocket`](/cookbook/react-to-state-websocket) for the +Cloud sandbox lifecycle (create → attach → start-task poll → delete) and the +socket subscription. Everything here builds directly on that; the sandbox and +attach code is intentionally identical so you can focus on the new parts. + +## What This Adds + +### 1. Both `ConversationStateUpdateEvent` shapes + +The agent-server reports state over the socket in **two** shapes, and they carry +the same `execution_status` field in different places: + +| Shape | Frame | Where `execution_status` lives | +| -------------- | ------------------------------------------------------------------------------------------------------ | ------------------------------ | +| **per-field** | `{"kind":"ConversationStateUpdateEvent","key":"execution_status","value":"finished"}` | `value` (a string) | +| **full-state** | `{"kind":"ConversationStateUpdateEvent","key":"full_state","value":{…,"execution_status":"finished"}}` | `value["execution_status"]` | + +`react-to-state-websocket` reads only the per-field shape — perfect for printing +transitions as they happen. For a reliable *terminal* check you want both, +because the authoritative confirmation comes in the full-state snapshot (next +point). `watch_terminal_state.py` handles both in `_status_from_event()`. + +### 2. `finished` is advisory; `error` / `stuck` are immediate + +The SDK treats a per-field `finished` as **provisional**: a `Stop` hook can +intercept the stop and resume the run (that is exactly what +[`finish-callback`](/cookbook/finish-callback) does). The **authoritative** signal is +the `full_state` snapshot emitted once the run settles. So this example: + +- remembers a per-field `finished` but does **not** exit on it, +- exits when a `full_state` snapshot reports `finished`, +- exits **immediately** on `error` or `stuck` in either shape (those are not + revertible). + +You can see both steps in the real run below: a provisional per-field `finished`, +then the confirming `full_state`. + +### 3. First-message auth (log-safe) + +The socket accepts the session key three ways: an `X-Session-API-Key` header, a +`?session_api_key=…` query parameter (**deprecated**), or a first WebSocket +frame `{"type":"auth","session_api_key":"…"}`. This example uses the **first +frame**, so the key never appears in the URL — and therefore never lands in +reverse-proxy or load-balancer access logs. (`resend_mode=all` stays in the +query string; it is not a secret.) + +## Run It + +```bash +export OH_API_KEY=... # your https://app.all-hands.dev API key +pip install requests websockets + +python watch_terminal_state.py +``` + +No LLM key is required: attaching through the Cloud app server injects your +account's configured LLM. + +### Flags + +| 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) | +| `--message` | — | `Say hello in one short sentence, then stop.` | +| `--poll-timeout` | `POLL_TIMEOUT` | `180` (sandbox / start-task) | +| `--watch-timeout` | — | `180` (socket watch) | +| `--keep` | — | off (deletes the sandbox at the end) | + +## What It Prints + +```text +sandbox: 5GcGcTMU1BSlCWzQBl7hhk + sandbox status: STARTING + sandbox status: RUNNING +agent: https://ffucfbirggvuhswm.prod-runtime.all-hands.dev + +=== attaching conversation (start-task poll) === + start-task status: SETTING_UP_SKILLS + start-task status: STARTING_CONVERSATION + start-task status: READY +conversation: 3477437940124f9c9adfef04f8cc5bb4 + +=== watching for terminal state (WebSocket) === +ws: connected — waiting for terminal state (no REST polling) + + >> execution_status=running (full_state) + .. SystemPromptEvent + .. MessageEvent + >> execution_status=running (per-field) + .. StreamingDeltaEvent + .. MessageEvent + >> execution_status=finished (per-field) + (provisional — awaiting full_state confirmation) + >> execution_status=finished (full_state) + +terminal state reached: finished +(confirmed over the WebSocket — no polling) + +Cleaning up sandbox… +``` + +## Notes + +- **No conversation-state polling.** Only the sandbox lifecycle and the + start-task are polled, and only for provisioning. Every `execution_status` + signal comes off the socket. +- **The terminal set** is `finished`, `error`, `stuck` + (`ConversationExecutionStatus.is_terminal()`). `idle` is excluded — it is also + the *initial* state before a run starts. +- **Simpler variant.** If you do not care about the advisory/confirmed + distinction, reading only the per-field shape (as + `react-to-state-websocket` does) and stopping on the first `finished` is + shorter — just slightly less precise if a Stop hook is in play. + +## Running Locally Without Cloud + +The audience for this example is **Cloud**. If you have no Cloud account, the +identical socket also runs against an agent-server you start yourself in Docker — +point `ws://localhost:/sockets/events/{id}` at it and pass the +`SESSION_API_KEY` you launched the container with. See +[`start-sandbox`](/cookbook/start-sandbox) for the agent-server image and +[`server-info-idle`](/cookbook/server-info-idle) for a local Docker fallback pattern. +The event handling in `watch_terminal_state.py` is unchanged; only how you obtain +the agent-server URL + session key differs. + +## APIs Used + +Same as [`react-to-state-websocket`](/cookbook/react-to-state-websocket): + +- **Cloud app server** (`https://app.all-hands.dev`, `X-Session-API-Key: `): + - `POST /api/v1/sandboxes` — start a sandbox + - `GET /api/v1/sandboxes?id=` — poll until `RUNNING` + - `POST /api/v1/app-conversations` — attach a conversation (returns a start task) + - `GET /api/v1/app-conversations/start-tasks?ids=` — poll for the id + - `DELETE /api/v1/sandboxes/{id}?sandbox_id=` — clean up +- **Agent server** (the sandbox's `AGENT_SERVER` exposed URL, `session_api_key`): + - `GET /sockets/events/{conversation_id}` — the **WebSocket** event stream + (`wss://…`), first-frame auth, `?resend_mode=all` to replay events emitted + before the socket connected. + +## Related + + + + start here: the basics of subscribing to the socket and reacting to every transition, with two ways to create the conversation (Cloud-attach vs. agent-direct) + + + + the coarse pull alternative: poll /server_info.idle_time for "the workspace has gone quiet" + + + + a Stop-hook callback on FINISHED (why per-field finished is advisory) + + diff --git a/cookbook/workspace-isolation.mdx b/cookbook/workspace-isolation.mdx new file mode 100644 index 000000000..bbd648409 --- /dev/null +++ b/cookbook/workspace-isolation.mdx @@ -0,0 +1,342 @@ +--- +title: Workspace Isolation +description: Enforce directory boundaries with hooks to prevent agents from navigating or writing outside assigned workspace. +icon: folder-tree +--- + +{/* GENERATED from OpenHands/enterprise-cookbook@main (workspace-isolation/README.md). Edit the source, not this file. */} + + + +An **advanced** hook example that enforces workspace isolation - preventing agents from navigating or writing outside their assigned directory. This is particularly useful when running **multiple parallel conversations on a local machine**, ensuring each conversation stays in its own sandbox without interfering with others. + +Based on the production implementation in [jpshackelford/lxa](https://github.com/jpshackelford/lxa/blob/main/src/hooks/sandbox.py). + +## What's in the Box + +The [`sandbox-enforcer/`](https://github.com/OpenHands/enterprise-cookbook/tree/main/workspace-isolation/sandbox-enforcer) plugin bundles: + +- **Hooks manifest** (`hooks/hooks.json`) - Two PreToolUse hooks (one for `terminal`, + one for `file_editor`) whose validation logic is **inlined** as POSIX-sh `command`s +- **Reference scripts** (`hooks/scripts/*.sh`) - The same logic as standalone files, + kept for readability/diffing (see the note under [Hook Scripts](#hook-scripts) on why + the manifest inlines them rather than referencing these files) +- **Skill** (`skills/sandbox-enforcer/SKILL.md`) - Documentation about isolation rules +- **Plugin manifest** (`.claude-plugin/plugin.json`) - Standard Claude Code plugin format + +## The Problem This Solves + +**Scenario:** You're running a local OpenHands instance with multiple conversations in parallel: + +```text +~/my-projects/ +├── web-app/ ← Conversation 1: "Refactor the auth module" +├── api-server/ ← Conversation 2: "Add rate limiting" +└── cli-tool/ ← Conversation 3: "Fix the parser bug" +``` + +**Without workspace isolation:** + +- Agent 1 might accidentally `cd ../api-server` and modify the wrong project +- Agent 2 could `rm -rf ../web-app/node_modules` while cleaning up +- Agent 3 might write test output to `/tmp` that conflicts with other agents + +**With workspace isolation:** + +- Each agent stays in its assigned directory +- Attempts to navigate or write outside are blocked with helpful messages +- Agents can still READ external files (with `# read-only` escape hatch) + +## How It Works + +```mermaid +flowchart TD + A["User to Agent 1: #quot;Navigate to /tmp and create a cache file#quot;"] --> B["Agent 1: *prepares: cd /tmp*"] + B --> C["PreToolUse Hook (terminal): *intercepts command*"] + C --> C1["Detects: cd /tmp (outside workspace)"] + C1 --> C2["Workspace: /home/user/my-projects/web-app"] + C2 --> C3["Returns exit code 2 (block) + message"] + C3 --> D["Agent 1: *receives block, explains to user*"] + D --> E["User: *Agent stays safely in web-app directory*"] +``` + +Meanwhile, Agent 2 and Agent 3 work independently in their own workspaces without risk of collision. + +## Protected Operations + +The plugin enforces isolation for: + +### Terminal Commands + +- **Navigation:** `cd`, `pushd`, `popd` to external directories +- **Write operations:** `rm`, `mv`, `cp`, `chmod`, `mkdir`, `touch`, `dd`, `ln`, etc. +- **Output redirection:** `>`, `>>` to external paths + +### File Editor Operations + +- **Write commands:** `create`, `str_replace`, `insert`, `undo_edit` to external paths +- **Read commands:** `view` is always allowed (can read anywhere) + +## Run It + + + + 1. Create multiple project directories: + + ```bash + mkdir -p ~/my-projects/{project-a,project-b,project-c} + ``` + + 2. Load the plugin in each conversation with different workspace dirs: + + ```bash + # Conversation 1 - workspace: ~/my-projects/project-a + cd ~/my-projects/project-a + # Load plugin with load-plugin example + + # Conversation 2 - workspace: ~/my-projects/project-b + cd ~/my-projects/project-b + # Load plugin with load-plugin example + ``` + + 3. Try cross-contamination (it will be blocked): + + ```bash + # In Conversation 1 + cd ../project-b # BLOCKED + cp file.txt ../project-b/ # BLOCKED + ``` + + + + ```bash + cd ../load-plugin + + # This will be blocked (cd outside workspace) + python load_plugin.py \ + --repo-path workspace-isolation/sandbox-enforcer \ + --message "Navigate to /tmp and list files there" + + # This will be allowed (within workspace) + python load_plugin.py \ + --repo-path workspace-isolation/sandbox-enforcer \ + --message "List all files in the current directory" + ``` + + + + [![Try Sandbox Enforcer](https://img.shields.io/badge/Try%20Sandbox%20Enforcer-blue)](https://app.all-hands.dev/launch?plugins=W3sic291cmNlIjogImdpdGh1YjpPcGVuSGFuZHMvZW50ZXJwcmlzZS1jb29rYm9vayIsICJyZWYiOiAibWFpbiIsICJyZXBvX3BhdGgiOiAid29ya3NwYWNlLWlzb2xhdGlvbi9zYW5kYm94LWVuZm9yY2VyIn1d\&message=Navigate%20to%20%2Ftmp%20and%20create%20a%20file%20there) + + + + + **Note:** In cloud ephemeral workspaces, this isolation is less critical (each conversation gets its own container), but it still demonstrates the technique for local setups. + + +## The `# read-only` Escape Hatch + +Sometimes you need to READ system files or shared resources outside your workspace. + +Add `# read-only` to your prompt: + +```bash +# ✓ Allowed - read-only access to system file +cat /etc/os-release # read-only + +# ✓ Allowed - check system information +uname -a # read-only + +# ✗ Still blocked - write operation +echo "data" > /tmp/output.txt # read-only (doesn't help with writes) +``` + +The hook detects the `# read-only` comment and allows non-destructive operations that reference external paths. + +## Hook Scripts + + + **Inline, not referenced.** When a plugin's PreToolUse hook fires, the hook + runner executes the `command` through `/bin/sh -c` with the working directory + set to the **agent's workspace**, not the plugin directory — and there is no + plugin-root path variable for hooks. A relative `hooks/scripts/validate_*.sh` + therefore won't resolve at runtime, and `{"type": "script", "path": ...}` is + **not** a supported hook type (only `command`, `prompt`, `agent`). So + `hooks/hooks.json` **inlines** each validator as a POSIX-sh `command`. The + `.sh` files below are byte-for-byte the same logic, kept as readable reference + (and easy to lint/diff); edit them and the inline `command` together. + + +### 1. Terminal Validation (`validate_terminal.sh`) + +Checks terminal commands for: + +- Navigation commands (`cd`, `pushd`, `popd`) trying to leave workspace +- Write commands (`rm`, `mv`, `cp`, `chmod`, `mkdir`, etc.) targeting external paths +- Output redirects (`>`, `>>`) to external files + +```bash +#!/bin/bash +input=$(cat) +workspace="${OPENHANDS_PROJECT_DIR:-$PWD}" + +# Check for navigation outside workspace +if [[ command is cd/pushd/popd to external path ]]; then + echo '{"decision": "deny", "reason": "Cannot navigate outside workspace..."}' + exit 2 +fi + +# Check for write operations outside workspace +# ... +``` + +### 2. File Editor Validation (`validate_file_editor.sh`) + +Checks file_editor operations: + +- Always allows `view` (read-only) +- Blocks `create`, `str_replace`, `insert`, `undo_edit` to external paths + +```bash +#!/bin/bash +input=$(cat) +workspace="${OPENHANDS_PROJECT_DIR:-$PWD}" + +# Always allow view +if [[ command == "view" ]]; then + exit 0 +fi + +# Block writes outside workspace +# ... +``` + +## Workspace Detection + +The hooks determine your workspace using (in order): + +1. **`OPENHANDS_PROJECT_DIR`** - Set by OpenHands Cloud/Enterprise +2. **`PWD`** - Current working directory (fallback) + +This makes it work in both local and cloud environments. + +## Plugin Structure + +```text +sandbox-enforcer/ +├── .claude-plugin/ +│ └── plugin.json # Plugin metadata +├── hooks/ +│ ├── hooks.json # Hook config (validators inlined as POSIX-sh commands) +│ └── scripts/ +│ ├── validate_terminal.sh # Reference copy of the terminal validator +│ └── validate_file_editor.sh # Reference copy of the file_editor validator +└── skills/ + └── sandbox-enforcer/ + └── SKILL.md # Documentation (auto-loaded) +``` + +This follows the **Claude Code plugin format**. The validation logic lives inline +in `hooks.json` (so it resolves at runtime as a plugin); the `scripts/*.sh` files +hold the identical logic for readability. + +## Comparison with Other Examples + +| Example | Approach | Security Level | Use Case | Complexity | +| ------------------------------------------------ | ---------------------------- | -------------- | ------------------------------------ | ------------ | +| [command-blacklist](/cookbook/command-blacklist) | Block dangerous patterns | Medium | General protection | Simple | +| [command-whitelist](/cookbook/command-whitelist) | Allow only approved commands | High | Read-only, educational | Simple | +| **workspace-isolation** (this) | Enforce directory boundaries | High | Parallel conversations, multi-tenant | **Advanced** | + +All three can be combined for defense-in-depth! + +## When to Use This + +**✅ Use workspace isolation when:** + +- Running multiple parallel conversations on one machine +- Each conversation works on a different project/directory +- You need to prevent cross-contamination between workspaces +- Building multi-tenant local development environments +- Teaching/educational scenarios with multiple students + +**⏭️ Skip workspace isolation when:** + +- Using cloud ephemeral workspaces (already isolated by containers) +- Running only one conversation at a time +- The agent needs to work across multiple project directories intentionally + +## Real-World Example: LXA + +This example is based on [jpshackelford/lxa](https://github.com/jpshackelford/lxa), a production tool that manages multiple OpenHands conversations in parallel: + +```text +lxa schedule --all # Process all jobs in parallel, each in its own workspace +├── Job 1: /data/job-1234/ (isolated) +├── Job 2: /data/job-5678/ (isolated) +└── Job 3: /data/job-9012/ (isolated) +``` + +The hooks ensure each job stays in its assigned directory, preventing interference. + +## Limitations + +This is a **simplified, heuristic-based implementation** with limitations: + +1. **No full shell parsing** - Complex quoting might bypass detection +2. **No path canonicalization** - Doesn't resolve symlinks or `.` / `..` fully +3. **Simplified command extraction** - May not catch all edge cases +4. **Fails open** - If hook can't parse input, it allows the operation (for safety) + +For production use, see the full [Python implementation in lxa](https://github.com/jpshackelford/lxa/blob/main/src/hooks/sandbox.py) which handles: + +- Full path resolution with `pathlib` +- Symlink following +- Complex shell command parsing with `shlex` +- Proper handling of shell metacharacters and quoting + +## Extending the Example + +Want to add more restrictions? Edit the inlined `command` in `hooks/hooks.json` +(and keep the reference `scripts/*.sh` in sync): + +```sh +# Add more blocked write commands, e.g. npm and pip: +if printf '%s' "$cmd_name" | grep -qE "^(rm|rmdir|mv|cp|chmod|chown|mkdir|touch|dd|ln|npm|pip)$"; then + # ... +fi +``` + +Keep the logic POSIX-sh and inline so it runs correctly when loaded as a plugin +(see the note under [Hook Scripts](#hook-scripts)). + +## Contributing + +This example prioritizes clarity and accessibility over completeness. If you're building a production system, refer to the [lxa implementation](https://github.com/jpshackelford/lxa/blob/main/src/hooks/sandbox.py) for a more robust approach. + +## Related + + + + Official documentation + + + + Production implementation + + + + Block dangerous commands + + + + Whitelist safe commands + + + + How to load this plugin + + + + No-code launcher + + diff --git a/docs.json b/docs.json index 8572c832f..35a035021 100644 --- a/docs.json +++ b/docs.json @@ -647,16 +647,58 @@ "cookbook/index" ] }, + { + "group": "Sandbox lifecycle", + "pages": [ + "cookbook/archive-sandbox", + "cookbook/clone-and-attach", + "cookbook/start-sandbox" + ] + }, { "group": "Conversation monitoring & reacting", "pages": [ - "cookbook/conversation-tags" + "cookbook/conversation-metrics", + "cookbook/conversation-tags", + "cookbook/finish-callback", + "cookbook/react-to-state-websocket", + "cookbook/server-info-idle", + "cookbook/watch-terminal-state" + ] + }, + { + "group": "Secrets & authentication", + "pages": [ + "cookbook/gpg-commit-signing", + "cookbook/per-conversation-secrets", + "cookbook/service-account-github-pat" + ] + }, + { + "group": "Plugins, skills & MCP", + "pages": [ + "cookbook/launch-plugin-badge", + "cookbook/load-plugin", + "cookbook/test-mcp-config", + "cookbook/upload-skills" + ] + }, + { + "group": "Custom agents & tools", + "pages": [ + "cookbook/custom-agent-no-browser", + "cookbook/custom-agent-with-pip-tool", + "cookbook/custom-agent-with-tool", + "cookbook/custom-system-prompt", + "cookbook/disabled-skills" ] }, { "group": "Guardrails", "pages": [ - "cookbook/command-blacklist" + "cookbook/command-blacklist", + "cookbook/command-whitelist", + "cookbook/workspace-isolation" ] } ]