Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: Check examples

on:
pull_request:
push:
branches: [main]

permissions:
contents: read

jobs:
python:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
with:
enable-cache: true
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: uv sync --locked --group dev
- run: uv run --locked pytest -q
- run: uv run --locked ruff check .
- run: uv run --locked ruff format --check .

typescript:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.14"
- working-directory: examples/typescript/task-runner
run: bun install --frozen-lockfile
- working-directory: examples/typescript/task-runner
run: bun run check
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ __pycache__/
*.py[cod]
.pytest_cache/
.coverage
htmlcov/
.ruff_cache/
downloads/
node_modules/
dist/
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
| Language | Example | What it demonstrates |
| --- | --- | --- |
| TypeScript | [Embedded task runner](examples/typescript/task-runner/) | Server-side SDK calls, a live `@mobilerun/react` device stream, status polling, and cancellation |
| Python | [Signed webhook receiver with file download](examples/python/webhook-file-receiver/) | HMAC verification, replay protection, secure SDK downloads, and atomic file storage |
| Python | [Signed webhook receiver with file download](examples/python/webhook-file-receiver/) | HMAC verification, secure SDK downloads, atomic file storage, and a protected live showcase UI |

Each example is self-contained, runnable, and documents the decisions you
should revisit before taking it to production.
Expand All @@ -59,14 +59,21 @@ Install the dependencies and run the test suite from this repository:

```bash
uv sync
uv run python -m unittest discover \
-s examples/python/webhook-file-receiver/tests \
-p 'test_*.py' -v
uv run --group dev pytest -q
```

Then open the [webhook receiver guide](examples/python/webhook-file-receiver/README.md)
for configuration and local run instructions.

## Check everything

After installing [uv](https://docs.astral.sh/uv/) and
[Bun](https://bun.sh/), run the repository check from the root:

```bash
./scripts/check.sh
```

## 📂 Repository layout

```text
Expand Down
15 changes: 15 additions & 0 deletions examples/python/webhook-file-receiver/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,23 @@ DOWNLOAD_DIR=./downloads
# mobilerun file API
MOBILERUN_BASE_URL=https://api.mobilerun.ai/v1
MOBILERUN_CLOUD_API_KEY=replace_with_your_api_key
# For a development key, use:
# MOBILERUN_BASE_URL=https://dev-api.mobilerun.ai/v1

# Optional limits
MAX_WEBHOOK_BODY_BYTES=65536
MAX_FILE_BYTES=52428800
MAX_TIMESTAMP_AGE_SECONDS=300

# Optional live showcase UI (showcase.py)
PUBLIC_URL=https://replace-with-your-tunnel.ngrok.app
SHOWCASE_TRIGGER_ID=replace-with-your-custom-trigger-uuid
# SHOWCASE_ACCESS_TOKEN=replace-with-a-long-random-value
# SHOWCASE_PREVIEW_MAX_BYTES=100000
# SHOWCASE_DELETE_WEBHOOK_ON_EXIT=true

# To reuse an existing endpoint, set all three values together. When omitted,
# showcase.py creates a temporary endpoint and deletes it on shutdown.
# SHOWCASE_WEBHOOK_ID=replace-with-your-webhook-uuid
# SHOWCASE_WEBHOOK_PATH=/mobilerun/replace-with-at-least-24-random-characters
# SHOWCASE_WEBHOOK_SECRET=whsec_replace_with_the_one_time_secret
64 changes: 61 additions & 3 deletions examples/python/webhook-file-receiver/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,17 @@ runtime.

```text
receiver.py # minimal executable entry point
showcase.py # protected live workflow demo server
showcase.html # presentation UI document
showcase.css # responsive visual design
showcase.js # session, launch, and live event client
mobilerun_webhook_receiver/
config.py # environment validation
security.py # HMAC and replay protection
downloader.py # SDK request and atomic file storage
application.py # webhook event processing
http_server.py # HTTP transport
showcase_events.py # bounded event replay for the showcase
```

The layers are composed through small interfaces, so authentication, download,
Expand All @@ -76,6 +81,9 @@ WEBHOOK_SECRET=whsec_...
DOWNLOAD_DIR=./downloads
```

Use `https://dev-api.mobilerun.ai/v1` as `MOBILERUN_BASE_URL` when testing
with a development API key.

`WEBHOOK_SECRET` is the one-time secret returned when the webhook endpoint is
created; it is not the API key or the private URL path.

Expand All @@ -99,13 +107,65 @@ ngrok http 3000
Configure the webhook endpoint URL as the ngrok HTTPS origin followed by
`WEBHOOK_PATH`.

## Live showcase UI

`showcase.py` adds a presentation-friendly workflow launcher, a live webhook
timeline, and an inline preview for small text files. Browser-facing APIs are
protected by a short-lived session cookie established from the access token in
the generated showcase URL. The webhook callback remains protected separately
by its Mobilerun HMAC signature.

Start ngrok first, then launch the showcase with your custom trigger ID:

```bash
ngrok http 3000
PUBLIC_URL=https://your-tunnel.ngrok.app \
SHOWCASE_TRIGGER_ID=your-custom-trigger-uuid \
uv run python showcase.py
```

The process prints one JSON object:

```json
{
"showcaseUrl": "https://your-tunnel.ngrok.app/#access_token=...",
"webhookUrl": "https://your-tunnel.ngrok.app/mobilerun/showcase-...",
"webhookId": "webhook-uuid",
"temporaryWebhook": true
}
```

Before starting the workflow, set the `endpointId` of its `webhooks.send`
action to the printed `webhookId`. Then open the complete `showcaseUrl`; the
fragment contains the private browser access token and is removed from the
address bar after the session is established.

The temporary endpoint is deleted when the process exits. The workflow action
will still contain its old ID, so update it with the newly printed ID on the
next run. To keep and reuse an endpoint instead, set `SHOWCASE_WEBHOOK_ID`,
`SHOWCASE_WEBHOOK_PATH`, and `SHOWCASE_WEBHOOK_SECRET` together; a reused
endpoint is never deleted by the showcase.

The optional `SHOWCASE_TRIGGER_ID` is strongly recommended for shared demos. It
limits the UI to one custom trigger and rejects attempts to fire another. When
it is omitted, the UI lists all custom triggers available to the API key.

Any verified `data.file` reference is downloaded with the same guarded, atomic
downloader as the minimal receiver. Small `text/*`, JSON, XML, and CSV files are
shown inline and retain a visible download button. Binary files remain
download-only. Configure the preview limit with `SHOWCASE_PREVIEW_MAX_BYTES`.

When a verified `workflow.step.emit` event contains `data.file`, the file is
saved under `DOWNLOAD_DIR` as:

```text
<file-id>--<original-filename>
```

This is a single-process showcase, not a hosted multi-user application. For a
long-running deployment, use persistent event storage, server-side user
authentication, rate limiting, and a production HTTP server.

## Expected file payload

```json
Expand Down Expand Up @@ -138,7 +198,5 @@ X-Mobilerun-Signature: sha256=<hex>
From the repository root:

```bash
uv run python -m unittest discover \
-s examples/python/webhook-file-receiver/tests \
-p 'test_*.py' -v
uv run --group dev pytest -q
```
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
from __future__ import annotations

import json
from collections.abc import Mapping
from http import HTTPStatus
from pathlib import Path
from typing import Any, Mapping, Protocol
from typing import Any, Protocol

from .config import Config
from .downloader import MobilerunFileDownloader
Expand Down Expand Up @@ -45,7 +46,7 @@ def handle(
if auth_error:
return auth_error

event_id = headers.get(EVENT_ID_HEADER, "")
event_id = _header(headers, EVENT_ID_HEADER)
event = self._parse_event(headers, raw_body, event_id)
if isinstance(event, tuple):
return event
Expand All @@ -64,8 +65,8 @@ def _authenticate(
try:
verify_signature(
raw_body,
headers.get(TIMESTAMP_HEADER, ""),
headers.get(SIGNATURE_HEADER, ""),
_header(headers, TIMESTAMP_HEADER),
_header(headers, SIGNATURE_HEADER),
self.config.webhook_secret,
max_age_ms=self.config.max_timestamp_age_ms,
)
Expand All @@ -89,7 +90,7 @@ def _parse_event(
return HTTPStatus.BAD_REQUEST, {"message": "event must be an object"}
if event.get("id") != event_id:
return HTTPStatus.BAD_REQUEST, {"message": "event id mismatch"}
event_type = headers.get(EVENT_TYPE_HEADER)
event_type = _header(headers, EVENT_TYPE_HEADER)
if event_type and event.get("type") != event_type:
return HTTPStatus.BAD_REQUEST, {"message": "event type mismatch"}
return event
Expand Down Expand Up @@ -147,3 +148,12 @@ def _file_data(event: Mapping[str, Any]) -> Mapping[str, Any] | None:
if not isinstance(file_data, dict):
raise PayloadError("event.data.file must be an object")
return file_data


def _header(headers: Mapping[str, str], name: str) -> str:
"""Read an HTTP header without depending on its wire-case spelling."""
wanted = name.casefold()
for key, value in headers.items():
if key.casefold() == wanted:
return value
return ""
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@
from __future__ import annotations

import os
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Mapping
from urllib.parse import urlsplit

from .errors import ConfigurationError


def _positive_int(env: Mapping[str, str], name: str, default: int) -> int:
def positive_int(env: Mapping[str, str], name: str, default: int) -> int:
raw = env.get(name, str(default))
try:
value = int(raw)
Expand All @@ -36,7 +36,7 @@ class Config:
max_timestamp_age_ms: int

@classmethod
def from_env(cls, env: Mapping[str, str] | None = None) -> "Config":
def from_env(cls, env: Mapping[str, str] | None = None) -> Config:
values = os.environ if env is None else env
webhook_path = values.get("WEBHOOK_PATH", "")
secret = values.get("WEBHOOK_SECRET", "")
Expand All @@ -53,24 +53,24 @@ def from_env(cls, env: Mapping[str, str] | None = None) -> "Config":
raise ConfigurationError("WEBHOOK_SECRET is required")
if not api_key:
raise ConfigurationError("MOBILERUN_CLOUD_API_KEY is required")
_validate_api_base_url(api_base_url)
validate_api_base_url(api_base_url)

age_seconds = _positive_int(values, "MAX_TIMESTAMP_AGE_SECONDS", 300)
age_seconds = positive_int(values, "MAX_TIMESTAMP_AGE_SECONDS", 300)
return cls(
host=values.get("HOST", "127.0.0.1"),
port=_positive_int(values, "PORT", 3000),
port=positive_int(values, "PORT", 3000),
webhook_path=webhook_path,
webhook_secret=secret.encode(),
api_base_url=api_base_url,
api_key=api_key,
download_dir=Path(values.get("DOWNLOAD_DIR", "./downloads")),
max_body_bytes=_positive_int(values, "MAX_WEBHOOK_BODY_BYTES", 65_536),
max_file_bytes=_positive_int(values, "MAX_FILE_BYTES", 50 * 1024**2),
max_body_bytes=positive_int(values, "MAX_WEBHOOK_BODY_BYTES", 65_536),
max_file_bytes=positive_int(values, "MAX_FILE_BYTES", 50 * 1024**2),
max_timestamp_age_ms=age_seconds * 1000,
)


def _validate_api_base_url(api_base_url: str) -> None:
def validate_api_base_url(api_base_url: str) -> None:
parsed = urlsplit(api_base_url)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ConfigurationError("MOBILERUN_BASE_URL must be an absolute HTTP(S) URL")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@
import os
import re
import tempfile
from collections.abc import Mapping
from contextlib import closing
from http import HTTPStatus
from pathlib import Path
from typing import Any, Mapping
from typing import Any
from urllib.parse import urlsplit
from uuid import UUID

Expand Down Expand Up @@ -90,9 +91,13 @@ def _validate(
return canonical_file_id, filename, download_path, expected_size

def _open_api_or_storage(self, download_path: str) -> httpx.Response:
# The SDK base URL already ends in /v1. Webhook downloadPath values are
# API-root paths that also start with /v1, so passing them through
# verbatim would request /v1/v1/agents/files/... and return 404.
sdk_path = download_path.removeprefix("/v1")
try:
response = self.sdk_client.get(
download_path,
sdk_path,
cast_to=httpx.Response,
options={
"headers": {"Accept": "application/octet-stream"},
Expand All @@ -101,6 +106,15 @@ def _open_api_or_storage(self, download_path: str) -> httpx.Response:
stream=True,
)
except APIError as exc:
# Stainless raises for the intentional 302 to signed object
# storage before returning the raw httpx response. Preserve the
# redirect handling below without ever forwarding API auth.
error_response = getattr(exc, "response", None)
if (
isinstance(error_response, httpx.Response)
and error_response.status_code in REDIRECT_STATUSES
):
return self._follow_storage_redirect(error_response)
raise DownloadError("mobilerun file API request failed") from exc

if response.status_code == HTTPStatus.OK:
Expand Down Expand Up @@ -198,6 +212,12 @@ def _required_string(data: Mapping[str, Any], key: str) -> str:
def _safe_filename(filename: str) -> str:
if filename in {".", ".."} or Path(filename).name != filename:
raise PayloadError("file.filename must not contain a path")
if "/" in filename or "\\" in filename or "\x00" in filename:
if (
"/" in filename
or "\\" in filename
or any(
ord(character) < 32 or ord(character) == 127 for character in filename
)
):
raise PayloadError("file.filename contains an unsafe character")
return filename
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
from __future__ import annotations

import json
from collections.abc import Mapping
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any, Mapping
from typing import Any

from .application import WebhookApplication
from .config import Config
Expand Down
Loading
Loading