From 5fda9d09f0ace2e137c0ad6e15e7b472d0e18c1b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 12 Aug 2026 14:07:48 -0700 Subject: [PATCH] feat(deploy): add experimental Cloudflare deployment template (#122768) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(deploy): add experimental Cloudflare template * fix(deploy): keep container SSH debug access opt-in * fix(deploy): satisfy scripts tsgo lane and model wrangler entrypoint in knip * fix(deploy): model wrangler-consumed exports and isolated dependency in knip The Worker default export and Durable Object class are instantiated by wrangler from wrangler.jsonc, and @cloudflare/containers lives in the template's isolated package.json — modeled per the deadcode checks' own guidance rather than root-manifest changes. * docs(deploy): align SSH bootstrap flow with the disabled-by-default policy --- .github/labeler.yml | 6 + config/knip.config.ts | 5 + config/knip.scripts-exports.config.ts | 4 + docs/docs.json | 1 + docs/install/cloudflare.md | 199 ++++++++++++++++++ docs/install/index.md | 8 +- scripts/cloudflare/Dockerfile | 24 +++ scripts/cloudflare/README.md | 164 +++++++++++++++ scripts/cloudflare/entrypoint.sh | 197 +++++++++++++++++ scripts/cloudflare/litestream.yml | 36 ++++ scripts/cloudflare/package.json | 16 ++ .../cloudflare/src/cloudflare-containers.d.ts | 13 ++ scripts/cloudflare/src/container.ts | 74 +++++++ scripts/cloudflare/src/index.ts | 29 +++ scripts/cloudflare/tsconfig.json | 15 ++ scripts/cloudflare/wrangler.jsonc | 52 +++++ 16 files changed, 841 insertions(+), 2 deletions(-) create mode 100644 docs/install/cloudflare.md create mode 100644 scripts/cloudflare/Dockerfile create mode 100644 scripts/cloudflare/README.md create mode 100644 scripts/cloudflare/entrypoint.sh create mode 100644 scripts/cloudflare/litestream.yml create mode 100644 scripts/cloudflare/package.json create mode 100644 scripts/cloudflare/src/cloudflare-containers.d.ts create mode 100644 scripts/cloudflare/src/container.ts create mode 100644 scripts/cloudflare/src/index.ts create mode 100644 scripts/cloudflare/tsconfig.json create mode 100644 scripts/cloudflare/wrangler.jsonc diff --git a/.github/labeler.yml b/.github/labeler.yml index ad75ed371b4d..785fc3d553fb 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -323,6 +323,12 @@ - "docs/install/docker.md" - "docs/tools/multi-agent-sandbox-tools.md" +"deploy: cloudflare": + - changed-files: + - any-glob-to-any-file: + - "scripts/cloudflare/**" + - "docs/install/cloudflare.md" + "agents": - changed-files: - any-glob-to-any-file: diff --git a/config/knip.config.ts b/config/knip.config.ts index 119ad944b234..2ff3d4a53e15 100644 --- a/config/knip.config.ts +++ b/config/knip.config.ts @@ -25,6 +25,8 @@ const repositoryScriptEntries = [ "scripts/check-control-ui-precompressed-assets.mts!", "scripts/check-live-cache.ts!", "scripts/check-package-dist-imports.mjs!", + // Cloudflare deployment template: wrangler bundles the Worker from this entry. + "scripts/cloudflare/src/index.ts!", "scripts/dev/ios-node-e2e.ts!", "scripts/diffs-shiki-curated.ts!", // Reusable Docker workflows invoke this from the downloaded .release-harness tree. @@ -434,6 +436,9 @@ const config = { ".": { ignoreDependencies: [ "@openclaw/*", + // Cloudflare template dependency: declared in scripts/cloudflare/package.json + // (isolated deploy tooling), not in the root manifest. + "@cloudflare/containers", // Docker packaging stages @openclaw/ai without nested dependencies after // verifying the root owns its exact runtime dependency versions. "@mistralai/mistralai", diff --git a/config/knip.scripts-exports.config.ts b/config/knip.scripts-exports.config.ts index 0e0cb8e5ea86..b0cc86fb0d45 100644 --- a/config/knip.scripts-exports.config.ts +++ b/config/knip.scripts-exports.config.ts @@ -58,6 +58,10 @@ const config = { ], // Oxlint consumes this required default export through a JSON config path. "scripts/oxlint-boundary-guards.mjs": ["exports"], + // Wrangler consumes the Worker default export and instantiates the Durable + // Object class by name from wrangler.jsonc; Knip cannot resolve either. + "scripts/cloudflare/src/index.ts": ["exports"], + "scripts/cloudflare/src/container.ts": ["exports"], "src/**": ["exports", "nsExports", "types", "nsTypes", "enumMembers", "namespaceMembers"], "test/**": ["exports", "nsExports", "types", "nsTypes", "enumMembers", "namespaceMembers"], }, diff --git a/docs/docs.json b/docs/docs.json index 3eb8a23d2175..5210dbaaeebe 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1115,6 +1115,7 @@ "group": "Hosting", "pages": [ "install/azure", + "install/cloudflare", "install/daytona", "install/digitalocean", "install/docker-vm-runtime", diff --git a/docs/install/cloudflare.md b/docs/install/cloudflare.md new file mode 100644 index 000000000000..6ddab5a93d9f --- /dev/null +++ b/docs/install/cloudflare.md @@ -0,0 +1,199 @@ +--- +summary: "Experimental Cloudflare Worker and Container deployment with Litestream backups to R2" +title: "Cloudflare Containers" +read_when: + - You want to run OpenClaw on Cloudflare Containers + - You are evaluating R2-backed SQLite recovery on ephemeral containers + - You need to choose between webhook scale-to-zero and always-on channels +--- + +Run one OpenClaw installation behind a Cloudflare Worker and a named Durable Object, with the official OpenClaw image and Litestream replication to R2. + + + This deployment target is experimental. Litestream protects SQLite databases, not the complete OpenClaw state directory. Read [Limits and recovery](#limits-and-recovery) before using production credentials. + + +## What you need + +- A Cloudflare account with Workers, Containers, and R2 available +- Docker Buildx with `linux/amd64` support +- A public Docker Hub repository for the derived image +- Node.js and npm +- Provider and channel credentials for your OpenClaw setup + +The template lives in [`scripts/cloudflare`](https://github.com/openclaw/openclaw/tree/main/scripts/cloudflare). It deploys a `standard-2` Container with `max_instances: 1`. + +## How it works + +The Worker forwards every HTTP and WebSocket request to one stable Durable Object name. That Durable Object owns one Container instance and is the single-writer fence around the Litestream replica. The Container exposes OpenClaw on port `8080`; `/startupz` is its traffic-readiness check. + +Litestream watches both SQLite roots: + +- `/home/node/.openclaw/state/*.sqlite` +- `/home/node/.openclaw/agents/**/*.sqlite` + +At boot, the entrypoint uses R2's S3 `ListObjectsV2` API as the restore manifest, rejects paths outside those roots, restores each discovered database, and only then starts the Gateway. + +## Deploy + + + + Clone OpenClaw and enter the template directory: + + ```bash + git clone https://github.com/openclaw/openclaw.git + cd openclaw/scripts/cloudflare + npm install + npx wrangler login + npx wrangler whoami + ``` + + Confirm that Wrangler selected the intended Cloudflare account before creating resources. + + + + + Create the bucket: + + ```bash + npx wrangler r2 bucket create openclaw-backups + ``` + + In the Cloudflare dashboard, create an R2 API token with object read/write access limited to that bucket. Keep the access key ID and secret access key out of the checkout. + + In `wrangler.jsonc`, replace `` in the endpoint. If you use another bucket name, update both `LITESTREAM_BUCKET` and `r2_buckets[].bucket_name`. + + The R2 binding is for Worker-side access and documentation completeness. Litestream cannot use a Worker binding from inside the Container; it uses R2's S3 endpoint and credentials passed through Worker secrets. + + + + + Replace `` in `Dockerfile` with an immutable digest from the official [`openclaw/openclaw`](https://hub.docker.com/r/openclaw/openclaw) Docker Hub repository. + + Build the derived image for Cloudflare's required architecture and push it to a public Docker Hub repository: + + ```bash + docker buildx build \ + --platform linux/amd64 \ + --tag docker.io//openclaw-cloudflare: \ + --push \ + . + docker buildx imagetools inspect \ + docker.io//openclaw-cloudflare: + ``` + + Replace the `containers[].image` placeholder in `wrangler.jsonc` with the resulting immutable `docker.io/...@sha256:...` reference. Cloudflare Containers can pull public Docker Hub images directly; GHCR is not a supported source for this template. + + + + + Compile the Worker and deploy it: + + ```bash + npm run check + npm run deploy + ``` + + The first deployment creates the Worker, the SQLite-backed Durable Object class, the Container application, and the R2 binding. + + + + + Add the R2 and Gateway credentials through Wrangler's secret prompt: + + ```bash + npx wrangler secret put LITESTREAM_ACCESS_KEY_ID + npx wrangler secret put LITESTREAM_SECRET_ACCESS_KEY + npx wrangler secret put OPENCLAW_GATEWAY_TOKEN + ``` + + Add provider and channel variables as needed. For example: + + ```bash + npx wrangler secret put OPENAI_API_KEY + npx wrangler secret put TELEGRAM_BOT_TOKEN + ``` + + `src/container.ts` passes an explicit allowlist of environment variables to the Container. Add another name there before using a different environment-backed credential. + + + + + First boot needs one interactive session inside the Container. SSH access ships disabled; enable it temporarily by adding this to the container entry in `wrangler.jsonc`, then redeploy: + + ```jsonc + "ssh": { "enabled": true } + ``` + + Open the deployed Worker URL once to start the instance. Then locate the application and instance IDs and connect: + + ```bash + npx wrangler containers list + npx wrangler containers instances --json + npx wrangler containers ssh + ``` + + SSH is wrangler-mediated and limited to accounts with container write access. After bootstrap you can remove the `ssh` block and redeploy; the restored state survives the replacement via Litestream. + + Inside the Container, run a SecretRef-based setup. This example uses OpenAI and Telegram: + + ```bash + cd /app + node openclaw.mjs onboard --non-interactive --accept-risk --skip-health \ + --mode local \ + --auth-choice openai-api-key \ + --secret-input-mode ref \ + --gateway-auth token \ + --gateway-token-ref-env OPENCLAW_GATEWAY_TOKEN \ + --skip-channels \ + --no-install-daemon + node openclaw.mjs channels add --channel telegram --use-env + node openclaw.mjs doctor --json + ``` + + Keep your exact bootstrap recipe in a private, reproducible runbook. A fresh Container disk does not retain the generated config. + + + + +## Choose the lifecycle mode + +`OPENCLAW_WEBHOOK_ONLY` defaults to `false`, which keeps the Container running through idle periods. Keep this default for channels that maintain sockets or long-lived processes, including: + +- Discord +- Slack Socket Mode +- WhatsApp + +Set `OPENCLAW_WEBHOOK_ONLY` to `true` only when every enabled channel receives traffic through HTTP webhooks. In that mode, the Container stops after ten idle minutes and cold-starts on the next request. + + + Scale-to-zero starts with a fresh disk. Enable it only when an external process can reapply your declarative bootstrap. Litestream restores SQLite but cannot recreate `openclaw.json`, credential files, installed plugins, or workspaces. + + +## Limits and recovery + +- **Single writer:** every request resolves the same Durable Object name, and Cloudflare runs one live Durable Object instance for that name. Do not increase `max_instances` or introduce alternate routing around this fence. A brief old/new Container overlap during a platform replacement or rollout is an accepted experimental tradeoff. +- **Recovery point:** the one-second Litestream sync interval normally produces a seconds-scale RPO. It is not synchronous replication, and abrupt termination can lose writes that have not reached R2. +- **Ephemeral disk:** every sleep, replacement, or host restart starts from the image plus the restored SQLite databases. Use [full OpenClaw archives](/install/backups#full-archives) for config, credential files, plugin files, and workspaces. +- **Rollback:** older database bytes are time travel. Ratcheting channel credentials, especially WhatsApp, can desynchronize; approvals and delivery/dedupe state also roll back. Relink affected channels and review pending approvals before resuming. See [Restore](/install/backups#restore). +- **WebSockets:** Worker and Container proxying supports WebSockets. Cloudflare limits each received WebSocket message to 32 MiB. +- **Egress:** outbound requests use shared Cloudflare IP space. This target does not provide a fixed egress address. +- **Provider boundary:** this is a deployment template, not an OpenClaw `cloudWorkers` provider. Its operator SSH access does not implement that provider's SSH execution contract. + +## Update + +Build a new derived image from a new immutable official OpenClaw digest, push it, update the derived digest in `wrangler.jsonc`, and deploy: + +```bash +npm run check +npm run deploy +``` + +Test updates and rollbacks against a separate R2 bucket first. Preserve current state before activating older bytes. + +## Related + +- [Backups](/install/backups) +- [Docker](/install/docker) +- [Gateway security](/gateway/security) +- [Secrets management](/gateway/secrets) diff --git a/docs/install/index.md b/docs/install/index.md index b76bc758ea2c..73f52c6dd1cb 100644 --- a/docs/install/index.md +++ b/docs/install/index.md @@ -186,10 +186,14 @@ If you want managed startup after install: Deploy OpenClaw on a cloud server or VPS. See [Linux server](/vps) for the full provider picker (DigitalOcean, Hetzner, Hostinger, Fly.io, GCP, Azure, Railway, -Northflank, Oracle Cloud, Raspberry Pi, and more), or deploy declaratively on -[Render](/install/render). +Northflank, Oracle Cloud, Raspberry Pi, and more), deploy declaratively on +[Render](/install/render), or try the experimental [Cloudflare Containers](/install/cloudflare) +template. + + Experimental Worker + Container deployment. + Pick a provider. diff --git a/scripts/cloudflare/Dockerfile b/scripts/cloudflare/Dockerfile new file mode 100644 index 000000000000..09522c4e7d31 --- /dev/null +++ b/scripts/cloudflare/Dockerfile @@ -0,0 +1,24 @@ +# Replace the placeholder with an immutable linux/amd64 digest from the official +# Docker Hub repository. Cloudflare Containers cannot pull OpenClaw from GHCR. +ARG OPENCLAW_IMAGE=openclaw/openclaw@sha256: +FROM ${OPENCLAW_IMAGE} + +USER root + +ARG LITESTREAM_VERSION=0.5.16 +ARG LITESTREAM_SHA256=9e29112380a942e4a62ee07773684396cb8b308dc4d67e130bef41f75e937f0a + +ADD --checksum=sha256:${LITESTREAM_SHA256} \ + https://github.com/benbjohnson/litestream/releases/download/v${LITESTREAM_VERSION}/litestream-${LITESTREAM_VERSION}-linux-x86_64.tar.gz \ + /tmp/litestream.tar.gz +RUN tar -xzf /tmp/litestream.tar.gz -C /usr/local/bin litestream \ + && chmod 0755 /usr/local/bin/litestream \ + && rm /tmp/litestream.tar.gz + +COPY litestream.yml /etc/litestream.yml +COPY entrypoint.sh /usr/local/bin/cloudflare-entrypoint.sh +RUN chmod 0755 /usr/local/bin/cloudflare-entrypoint.sh \ + && chown root:root /etc/litestream.yml /usr/local/bin/cloudflare-entrypoint.sh + +USER node +ENTRYPOINT ["tini", "-s", "--", "/usr/local/bin/cloudflare-entrypoint.sh"] diff --git a/scripts/cloudflare/README.md b/scripts/cloudflare/README.md new file mode 100644 index 000000000000..a1dd643fe6d7 --- /dev/null +++ b/scripts/cloudflare/README.md @@ -0,0 +1,164 @@ +# OpenClaw on Cloudflare Containers (experimental) + +This template runs one OpenClaw installation behind a Cloudflare Worker and one named Durable Object. The Durable Object starts a `standard-2` Container from a public, digest-pinned Docker Hub image. Litestream continuously replicates the global and per-agent SQLite databases to R2 through its S3-compatible API. + +This is an experimental deployment target. Read [Operational constraints](#operational-constraints) before using it with real credentials or relying on it for recovery. + +## Architecture + +```text +HTTP/WebSocket request + | + v +Cloudflare Worker + | + v +OpenClawContainer Durable Object (one stable name) + | + v +OpenClaw + Litestream container :8080 + | + +--> R2 S3 API (SQLite replicas) +``` + +Every HTTP and WebSocket request is forwarded to port `8080`. The Container helper checks `GET /startupz` before admitting traffic. `max_instances: 1` and the single Durable Object name are the installation's outer single-writer fence. + +## Prerequisites + +- A Cloudflare account with Workers, Containers, and R2 available +- Docker Buildx with `linux/amd64` support +- A public Docker Hub repository for the derived image +- Node.js and npm +- Model-provider and channel credentials for the OpenClaw setup you choose + +## 1. Create the R2 bucket and S3 credentials + +From this directory: + +```bash +npm install +npx wrangler login +npx wrangler whoami +npx wrangler r2 bucket create openclaw-backups +``` + +In the Cloudflare dashboard, create an R2 API token with object read/write access limited to this bucket. Record its access key ID and secret access key. Do not put either value in this checkout. + +Edit `wrangler.jsonc`: + +- replace `` in `LITESTREAM_ENDPOINT` +- change both `LITESTREAM_BUCKET` and `r2_buckets[].bucket_name` if you chose another bucket name + +The R2 binding is present for Worker-side completeness. Litestream runs inside the Container and cannot consume a Worker binding directly, so it uses R2's S3 endpoint and Worker secrets passed through `envVars`. + +## 2. Build and publish the image + +Choose an immutable, architecture-compatible digest from the official [`openclaw/openclaw`](https://hub.docker.com/r/openclaw/openclaw) Docker Hub repository. Replace `` in `Dockerfile`, then build and push the derived image: + +```bash +docker buildx build \ + --platform linux/amd64 \ + --tag docker.io//openclaw-cloudflare: \ + --push \ + . +``` + +Make the derived repository public. Resolve its pushed digest, then replace the `containers[].image` placeholder in `wrangler.jsonc`: + +```bash +docker buildx imagetools inspect docker.io//openclaw-cloudflare: +``` + +Use the resulting immutable `docker.io//openclaw-cloudflare@sha256:` reference. Cloudflare Containers can pull public Docker Hub images, but not GHCR images directly. + +## 3. Deploy and set secrets + +The first deploy creates the Worker, Durable Object migration, Container application, and R2 binding: + +```bash +npm run check +npm run deploy +``` + +Immediately add the R2 and Gateway secrets. `wrangler secret put` prompts without writing the value to shell history: + +```bash +npx wrangler secret put LITESTREAM_ACCESS_KEY_ID +npx wrangler secret put LITESTREAM_SECRET_ACCESS_KEY +npx wrangler secret put OPENCLAW_GATEWAY_TOKEN +``` + +Add the provider and channel variables needed by your installation, for example: + +```bash +npx wrangler secret put OPENAI_API_KEY +npx wrangler secret put TELEGRAM_BOT_TOKEN +``` + +`src/container.ts` passes the listed optional secret names to the Container. Add another explicit name there before using a different environment-backed provider or channel credential. + +## 4. Bootstrap OpenClaw + +Open the deployed Worker URL once to start the named instance. Then find the Container application and instance IDs: + +```bash +npx wrangler containers list +npx wrangler containers instances --json +npx wrangler containers ssh +``` + +Inside the Container, run the non-interactive SecretRef bootstrap. This example uses OpenAI and Telegram; select the provider and webhook-capable channel that match your secrets: + +```bash +cd /app +node openclaw.mjs onboard --non-interactive --accept-risk --skip-health \ + --mode local \ + --auth-choice openai-api-key \ + --secret-input-mode ref \ + --gateway-auth token \ + --gateway-token-ref-env OPENCLAW_GATEWAY_TOKEN \ + --skip-channels \ + --no-install-daemon +node openclaw.mjs channels add --channel telegram --use-env +node openclaw.mjs doctor --json +``` + +Keep the exact bootstrap recipe in a private, reproducible runbook. Litestream does not replicate `openclaw.json`, credential files, installed plugin files, or workspaces. + +## Scale-to-zero policy + +The template defaults `OPENCLAW_WEBHOOK_ONLY` to `false`. This keeps the Container alive across idle periods for Discord, Slack Socket Mode, WhatsApp, and every other channel that maintains a socket or polling process. + +Set `OPENCLAW_WEBHOOK_ONLY` to `true` only when every enabled channel receives traffic through HTTP webhooks. The Container then stops after ten idle minutes and cold-starts on the next request. Because its disk is fresh after sleep, enable this only when an external process can reapply the declarative bootstrap above; Litestream alone restores SQLite, not the config files needed to activate channels. + +## Operational constraints + +- **Experimental:** Cloudflare Container lifecycle and rollout behavior can change. Test crash, sleep, rollout, and restore paths with non-production credentials first. +- **Single-writer fence:** Cloudflare guarantees one live Durable Object instance for a given name, and all Worker requests use the same name. This is the fence around one Litestream replica. A brief old/new Container overlap during replacement or rollout remains an accepted experimental tradeoff; do not raise `max_instances` or route around the named object. +- **Ephemeral disk:** Every Container restart or sleep starts with a fresh filesystem. The entrypoint lists R2 objects, derives the concrete SQLite restore manifest, restores each database, then starts OpenClaw under Litestream. +- **Partial durability:** Litestream covers `/home/node/.openclaw/state/*.sqlite` and recursive per-agent SQLite databases only. Use a separate, private [`openclaw backup create`](https://docs.openclaw.ai/install/backups#full-archives) workflow for config, credential files, plugins, and workspaces. +- **RPO:** `sync-interval: 1s` normally yields a seconds-scale recovery point, not zero data loss. Abrupt termination can lose writes that were not uploaded yet. +- **Rollback is time travel:** Restoring older state can desynchronize ratcheting channel credentials (especially WhatsApp), roll back approvals, and roll back delivery/dedupe state. Relink affected channels and review pending approvals before resuming. +- **WebSocket limit:** Cloudflare accepts received WebSocket messages up to 32 MiB. The Worker/Container proxy supports WebSockets; larger individual messages are closed by the platform. +- **Egress identity:** outbound traffic comes from shared Cloudflare IP space. Providers that require a fixed source IP need another deployment target or an approved egress design. +- **Not a `cloudWorkers` provider:** this is a hosting template. Operator SSH access is enabled for bootstrap, but the template does not implement OpenClaw's SSH-based cloud-worker provider contract. + +## Updating + +Build a new derived image from a new immutable official OpenClaw digest, push it, replace the derived digest in `wrangler.jsonc`, and run: + +```bash +npm run check +npm run deploy +``` + +Treat rollbacks like restores: stop traffic where possible, preserve the current state first, and review credentials, approvals, and delivery state before activating older database bytes. + +## Files + +- `wrangler.jsonc`: Worker, Durable Object, Container application, and R2 binding +- `src/index.ts`: routes all HTTP and WebSocket traffic to one named instance +- `src/container.ts`: Container port, readiness, environment, and sleep policy +- `Dockerfile`: official OpenClaw image plus pinned Litestream for `linux/amd64` +- `entrypoint.sh`: R2 LIST restore discovery, containment checks, and restore-then-exec flow +- `litestream.yml`: watched global and per-agent SQLite directory replicas diff --git a/scripts/cloudflare/entrypoint.sh b/scripts/cloudflare/entrypoint.sh new file mode 100644 index 000000000000..fc663fa113da --- /dev/null +++ b/scripts/cloudflare/entrypoint.sh @@ -0,0 +1,197 @@ +#!/bin/sh +set -eu + +STATE_ROOT=/home/node/.openclaw +CONFIG=/etc/litestream.yml + +mkdir -p "$STATE_ROOT/state" "$STATE_ROOT/agents" + +log() { + printf '[cloudflare-entrypoint] %s\n' "$*" +} + +replica_url_for_db() { + db_path=$1 + resolved_path=$(realpath -m "$db_path") + case "$resolved_path" in + "$STATE_ROOT"/state/*.sqlite) + relative_path=${resolved_path#"$STATE_ROOT/state/"} + replica_path="replicas/state/$relative_path" + ;; + # case globs match "/" (fnmatch without FNM_PATHNAME), so this accepts the + # nested canonical layout agents//agent/openclaw-agent.sqlite. + "$STATE_ROOT"/agents/*.sqlite) + relative_path=${resolved_path#"$STATE_ROOT/agents/"} + replica_path="replicas/agents/$relative_path" + ;; + *) + log "refusing restore path outside configured directory roots: $db_path" + return 1 + ;; + esac + + printf 's3://%s/%s?endpoint=%s®ion=%s&forcePathStyle=true\n' \ + "$LITESTREAM_BUCKET" "$replica_path" "$LITESTREAM_ENDPOINT" "$LITESTREAM_REGION" +} + +list_replica_databases() { + node --input-type=module <<'NODE' +import { createHash, createHmac } from "node:crypto"; + +const { + LITESTREAM_ACCESS_KEY_ID: accessKeyId, + LITESTREAM_BUCKET: bucket, + LITESTREAM_ENDPOINT: endpoint, + LITESTREAM_REGION: region, + LITESTREAM_SECRET_ACCESS_KEY: secretAccessKey, +} = process.env; + +for (const [name, value] of Object.entries({ + LITESTREAM_ACCESS_KEY_ID: accessKeyId, + LITESTREAM_BUCKET: bucket, + LITESTREAM_ENDPOINT: endpoint, + LITESTREAM_REGION: region, + LITESTREAM_SECRET_ACCESS_KEY: secretAccessKey, +})) { + if (!value) { + throw new Error(`${name} is required`); + } +} + +const sha256 = (value) => createHash("sha256").update(value).digest("hex"); +const hmac = (key, value) => createHmac("sha256", key).update(value).digest(); +const encode = (value) => + encodeURIComponent(value).replace(/[!'()*]/g, (char) => + `%${char.charCodeAt(0).toString(16).toUpperCase()}`, + ); + +function decodeXml(value) { + return value + .replace(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCodePoint(Number.parseInt(code, 16))) + .replace(/&#([0-9]+);/g, (_, code) => String.fromCodePoint(Number.parseInt(code, 10))) + .replaceAll(""", '"') + .replaceAll("'", "'") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("&", "&"); +} + +async function listPage(continuationToken) { + const query = [ + ["encoding-type", "url"], + ["list-type", "2"], + ["prefix", "replicas/"], + ]; + if (continuationToken) { + query.push(["continuation-token", continuationToken]); + } + query.sort(([left], [right]) => left.localeCompare(right)); + const canonicalQuery = query.map(([key, value]) => `${encode(key)}=${encode(value)}`).join("&"); + + const url = new URL(endpoint); + url.pathname = `${url.pathname.replace(/\/$/, "")}/${encode(bucket)}`; + url.search = canonicalQuery; + + const now = new Date(); + const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, ""); + const date = amzDate.slice(0, 8); + const payloadHash = sha256(""); + const canonicalHeaders = + `host:${url.host}\n` + + `x-amz-content-sha256:${payloadHash}\n` + + `x-amz-date:${amzDate}\n`; + const signedHeaders = "host;x-amz-content-sha256;x-amz-date"; + const canonicalRequest = [ + "GET", + url.pathname, + canonicalQuery, + canonicalHeaders, + signedHeaders, + payloadHash, + ].join("\n"); + const scope = `${date}/${region}/s3/aws4_request`; + const stringToSign = ["AWS4-HMAC-SHA256", amzDate, scope, sha256(canonicalRequest)].join("\n"); + const dateKey = hmac(`AWS4${secretAccessKey}`, date); + const regionKey = hmac(dateKey, region); + const serviceKey = hmac(regionKey, "s3"); + const signingKey = hmac(serviceKey, "aws4_request"); + const signature = createHmac("sha256", signingKey).update(stringToSign).digest("hex"); + + const response = await fetch(url, { + headers: { + Authorization: + `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${scope},` + + `SignedHeaders=${signedHeaders},Signature=${signature}`, + "x-amz-content-sha256": payloadHash, + "x-amz-date": amzDate, + }, + }); + if (!response.ok) { + throw new Error(`R2 ListObjectsV2 failed with HTTP ${response.status}`); + } + + const xml = await response.text(); + const keys = [...xml.matchAll(/([\s\S]*?)<\/Key>/g)].map((match) => + decodeURIComponent(decodeXml(match[1])), + ); + const tokenMatch = xml.match(/([\s\S]*?)<\/NextContinuationToken>/); + return { + keys, + nextToken: tokenMatch ? decodeXml(tokenMatch[1]) : undefined, + }; +} + +function localDatabasePath(key) { + const match = /^replicas\/(state|agents)\/(.+\.sqlite)\/\d{4}\/[^/]+\.ltx$/.exec(key); + if (!match) { + return undefined; + } + + const [, root, relativePath] = match; + const segments = relativePath.split("/"); + if (segments.some((segment) => !segment || segment === "." || segment === ".." || /\s/.test(segment))) { + throw new Error(`unsafe replica database path in R2 listing: ${key}`); + } + return `/home/node/.openclaw/${root}/${segments.join("/")}`; +} + +const databasePaths = new Set(); +let continuationToken; +do { + const page = await listPage(continuationToken); + for (const key of page.keys) { + const databasePath = localDatabasePath(key); + if (databasePath) { + databasePaths.add(databasePath); + } + } + continuationToken = page.nextToken; +} while (continuationToken); + +for (const databasePath of [...databasePaths].sort()) { + console.log(databasePath); +} +NODE +} + +# Directory replication appends each database's relative path to the replica +# prefix. Restore therefore uses an R2 ListObjectsV2 result as its manifest. +if ! find "$STATE_ROOT/state" "$STATE_ROOT/agents" -type f -name '*.sqlite' -print -quit | grep -q .; then + export AWS_ACCESS_KEY_ID="${AWS_ACCESS_KEY_ID:-$LITESTREAM_ACCESS_KEY_ID}" + export AWS_SECRET_ACCESS_KEY="${AWS_SECRET_ACCESS_KEY:-$LITESTREAM_SECRET_ACCESS_KEY}" + export AWS_REGION="${AWS_REGION:-$LITESTREAM_REGION}" + + restore_databases=$(list_replica_databases) + for db_path in $restore_databases; do + replica_url=$(replica_url_for_db "$db_path") + mkdir -p "$(dirname "$db_path")" + log "restoring database: $db_path" + litestream restore -if-replica-exists -integrity-check quick -o "$db_path" "$replica_url" + done +else + log "sqlite state already present; restore skipped" +fi + +log "starting Litestream replication with OpenClaw gateway child" +exec litestream replicate -config "$CONFIG" \ + -exec "node openclaw.mjs gateway --allow-unconfigured --bind lan --port 8080 --auth token" diff --git a/scripts/cloudflare/litestream.yml b/scripts/cloudflare/litestream.yml new file mode 100644 index 000000000000..55bdc430a12e --- /dev/null +++ b/scripts/cloudflare/litestream.yml @@ -0,0 +1,36 @@ +# Both roots need watch:true because OpenClaw creates the shared and per-agent +# databases after Litestream starts on a new ephemeral container. +sync-interval: 1s +logging: + level: INFO + type: text + stderr: false + +dbs: + - dir: /home/node/.openclaw/state + pattern: "*.sqlite" + recursive: false + watch: true + replica: + type: s3 + bucket: ${LITESTREAM_BUCKET} + path: replicas/state + endpoint: ${LITESTREAM_ENDPOINT} + region: ${LITESTREAM_REGION} + access-key-id: ${LITESTREAM_ACCESS_KEY_ID} + secret-access-key: ${LITESTREAM_SECRET_ACCESS_KEY} + force-path-style: true + + - dir: /home/node/.openclaw/agents + pattern: "*.sqlite" + recursive: true + watch: true + replica: + type: s3 + bucket: ${LITESTREAM_BUCKET} + path: replicas/agents + endpoint: ${LITESTREAM_ENDPOINT} + region: ${LITESTREAM_REGION} + access-key-id: ${LITESTREAM_ACCESS_KEY_ID} + secret-access-key: ${LITESTREAM_SECRET_ACCESS_KEY} + force-path-style: true diff --git a/scripts/cloudflare/package.json b/scripts/cloudflare/package.json new file mode 100644 index 000000000000..26562cadb7c3 --- /dev/null +++ b/scripts/cloudflare/package.json @@ -0,0 +1,16 @@ +{ + "name": "openclaw-cloudflare-template", + "private": true, + "type": "module", + "scripts": { + "check": "tsc --noEmit -p tsconfig.json", + "deploy": "wrangler deploy" + }, + "dependencies": { + "@cloudflare/containers": "0.3.7" + }, + "devDependencies": { + "typescript": "6.0.3", + "wrangler": "4.122.0" + } +} diff --git a/scripts/cloudflare/src/cloudflare-containers.d.ts b/scripts/cloudflare/src/cloudflare-containers.d.ts new file mode 100644 index 000000000000..4e4de4bb2ddc --- /dev/null +++ b/scripts/cloudflare/src/cloudflare-containers.d.ts @@ -0,0 +1,13 @@ +// Keep this deployment template type-checkable without adding Cloudflare packages +// to the OpenClaw workspace. The isolated package.json supplies the runtime module. +declare module "@cloudflare/containers" { + export class Container { + constructor(ctx: unknown, env: Env); + defaultPort?: number; + envVars: Record; + pingEndpoint: string; + sleepAfter: string | number; + fetch(request: Request): Promise; + onActivityExpired(): Promise; + } +} diff --git a/scripts/cloudflare/src/container.ts b/scripts/cloudflare/src/container.ts new file mode 100644 index 000000000000..834ff722ea4e --- /dev/null +++ b/scripts/cloudflare/src/container.ts @@ -0,0 +1,74 @@ +import { Container } from "@cloudflare/containers"; + +interface OpenClawContainerEnv { + ANTHROPIC_API_KEY?: string; + DISCORD_BOT_TOKEN?: string; + LITESTREAM_ACCESS_KEY_ID: string; + LITESTREAM_BUCKET: string; + LITESTREAM_ENDPOINT: string; + LITESTREAM_REGION: string; + LITESTREAM_SECRET_ACCESS_KEY: string; + OPENAI_API_KEY?: string; + OPENCLAW_GATEWAY_TOKEN: string; + OPENCLAW_WEBHOOK_ONLY: string; + SLACK_APP_TOKEN?: string; + SLACK_BOT_TOKEN?: string; + TELEGRAM_BOT_TOKEN?: string; +} + +const OPTIONAL_SECRET_NAMES = [ + "ANTHROPIC_API_KEY", + "DISCORD_BOT_TOKEN", + "OPENAI_API_KEY", + "SLACK_APP_TOKEN", + "SLACK_BOT_TOKEN", + "TELEGRAM_BOT_TOKEN", +] as const; + +function buildContainerEnv(env: OpenClawContainerEnv): Record { + const containerEnv: Record = { + LITESTREAM_ACCESS_KEY_ID: env.LITESTREAM_ACCESS_KEY_ID, + LITESTREAM_BUCKET: env.LITESTREAM_BUCKET, + LITESTREAM_ENDPOINT: env.LITESTREAM_ENDPOINT, + LITESTREAM_REGION: env.LITESTREAM_REGION, + LITESTREAM_SECRET_ACCESS_KEY: env.LITESTREAM_SECRET_ACCESS_KEY, + OPENCLAW_GATEWAY_TOKEN: env.OPENCLAW_GATEWAY_TOKEN, + }; + + for (const [name, value] of Object.entries(containerEnv)) { + if (!value) { + throw new Error(`missing required Worker variable or secret: ${name}`); + } + } + + for (const name of OPTIONAL_SECRET_NAMES) { + const value = env[name]; + if (value) { + containerEnv[name] = value; + } + } + + return containerEnv; +} + +export class OpenClawContainer extends Container { + override defaultPort = 8080; + override pingEndpoint = "localhost/startupz"; + override sleepAfter = "10m"; + + private readonly webhookOnly: boolean; + + constructor(ctx: unknown, env: OpenClawContainerEnv) { + super(ctx, env); + this.envVars = buildContainerEnv(env); + this.webhookOnly = env.OPENCLAW_WEBHOOK_ONLY === "true"; + } + + override async onActivityExpired(): Promise { + // Socket channels need a continuously running process. Only an explicitly + // webhook-only installation may let the Container helper stop the instance. + if (this.webhookOnly) { + await super.onActivityExpired(); + } + } +} diff --git a/scripts/cloudflare/src/index.ts b/scripts/cloudflare/src/index.ts new file mode 100644 index 000000000000..265fa7dec6ca --- /dev/null +++ b/scripts/cloudflare/src/index.ts @@ -0,0 +1,29 @@ +export { OpenClawContainer } from "./container.js"; + +interface ContainerStub { + fetch(request: Request): Promise; +} + +interface ContainerNamespace { + getByName(name: string): ContainerStub; +} + +interface WorkerEnv { + OPENCLAW_CONTAINER: ContainerNamespace; +} + +interface WorkerHandler { + fetch(request: Request, env: WorkerEnv): Promise; +} + +// One stable name gives the installation one globally unique Durable Object. +// That object is the outer single-writer fence for the Litestream replica. +const INSTALLATION_INSTANCE = "openclaw-installation"; + +const worker: WorkerHandler = { + async fetch(request, env) { + return env.OPENCLAW_CONTAINER.getByName(INSTALLATION_INSTANCE).fetch(request); + }, +}; + +export default worker; diff --git a/scripts/cloudflare/tsconfig.json b/scripts/cloudflare/tsconfig.json new file mode 100644 index 000000000000..16818a99914b --- /dev/null +++ b/scripts/cloudflare/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "lib": ["ES2022", "WebWorker"], + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2022", + "types": [] + }, + "include": ["src/**/*.ts"] +} diff --git a/scripts/cloudflare/wrangler.jsonc b/scripts/cloudflare/wrangler.jsonc new file mode 100644 index 000000000000..49cf0033ad0c --- /dev/null +++ b/scripts/cloudflare/wrangler.jsonc @@ -0,0 +1,52 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "openclaw-cloudflare", + "main": "src/index.ts", + "compatibility_date": "2026-08-12", + "compatibility_flags": ["nodejs_compat"], + "observability": { + "enabled": true, + }, + "vars": { + "LITESTREAM_BUCKET": "openclaw-backups", + "LITESTREAM_ENDPOINT": "https://.r2.cloudflarestorage.com", + "LITESTREAM_REGION": "auto", + // Keep false for Discord, Slack Socket Mode, WhatsApp, or any other socket channel. + "OPENCLAW_WEBHOOK_ONLY": "false", + }, + "durable_objects": { + "bindings": [ + { + "name": "OPENCLAW_CONTAINER", + "class_name": "OpenClawContainer", + }, + ], + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["OpenClawContainer"], + }, + ], + "r2_buckets": [ + { + // Documentation/Worker access only. Litestream uses the R2 S3 API and + // credentials supplied with `wrangler secret put`, not this binding. + "binding": "OPENCLAW_BACKUPS", + "bucket_name": "openclaw-backups", + }, + ], + "containers": [ + { + "name": "openclaw-cloudflare", + "class_name": "OpenClawContainer", + // Build scripts/cloudflare/Dockerfile for linux/amd64, publish it publicly + // on Docker Hub, then replace this placeholder with its immutable digest. + "image": "docker.io//openclaw-cloudflare@sha256:", + "instance_type": "standard-2", + "max_instances": 1, + // For debugging you can add `"ssh": { "enabled": true }` to allow + // wrangler-mediated SSH for accounts with container write access. + }, + ], +}