diff --git a/README.md b/README.md index f905c7f7..d9cc474f 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,12 @@ Console UI, operator gotchas): **[docs.openma.dev/self-host/overview](https://do ## Quick start: Cloudflare deploy -Requires [Workers Paid plan](https://developers.cloudflare.com/workers/platform/pricing/) (for Durable Objects + Containers). +[![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/open-ma/open-managed-agents) + +> **Note:** The Deploy button above deploys the default (Paid plan) configuration. +> For Free Tier setup, see [Cloudflare Free Tier](#cloudflare-free-tier) below. + +Requires [Workers Paid plan](https://developers.cloudflare.com/workers/platform/pricing/) (for Durable Objects + Containers) for full functionality. ```bash git clone https://github.com/open-ma/open-managed-agents.git @@ -118,6 +123,13 @@ npm run deploy # → https://openma.dev (or https://managed-agents..workers.dev for a personal deploy) ``` +Or use the interactive setup wizard (recommended for new deployments): + +```bash +./scripts/setup-cf.sh # Standard deployment (Paid plan) +./scripts/setup-cf.sh --free-tier # Free Tier deployment +``` + What gets deployed: | Component | What it does | @@ -158,6 +170,85 @@ For long-lived sessions use `GET /v1/sessions/$SESSION/events/stream` — replay --- +## Cloudflare Free Tier + +OMA can be deployed on the [Cloudflare Free Tier](https://developers.cloudflare.com/workers/platform/pricing/), though some features are unavailable. + +### Limitations + +| Feature | Free Tier Status | Details | +|---|---|---| +| **Workers Containers** (sandbox) | ❌ Unavailable | Tool execution (`bash`, `read`, `write`, `edit`, etc.) requires Cloudflare Workers Containers (Paid plan). The API, Console UI, and agent/session management still function. | +| **Browser Rendering** | ❌ Unavailable | The `browser` tool is opt-in and gracefully degrades when the binding is absent. | +| **Rate Limiting** | ❌ Unavailable | Rate limit bindings soft-pass when absent. Consider Cloudflare's [WAF dashboard rules](https://developers.cloudflare.com/waf/) as an alternative. | +| **Memory Queue** (R2 events) | ❌ Unavailable | Memory store audit via queues won't function. REST writes still audit inline (D1), but agent FUSE writes won't be audited. | +| **Durable Objects** | ✅ Available | Durable Objects are included in the Free Tier (limited operations). | +| **D1 Databases** | ✅ Available | Included in the Free Tier (limited storage). | +| **R2 Storage** | ✅ Available | Included in the Free Tier (limited storage). | +| **KV Storage** | ✅ Available | Included in the Free Tier (limited operations). | +| **Workers AI** | ✅ Available | Included in the Free Tier (limited requests). | +| **API & Console** | ✅ Available | Full API and Console UI functionality. | +| **Integrations** | ✅ Available | Linear, GitHub, and Slack integrations work. | + +### Free Tier Quick Start + +```bash +git clone https://github.com/open-ma/open-managed-agents.git +cd open-managed-agents +pnpm install + +# Use the interactive setup script with the --free-tier flag +./scripts/setup-cf.sh --free-tier +``` + +The `--free-tier` flag will: +1. Create all required resources (D1, KV, R2) +2. Patch the wrangler.jsonc configuration files +3. Set required secrets +4. Apply database migrations +5. **Skip** provisioning paid-only resources (queues, containers, browser, rate limits) +6. Deploy the workers + +### Manual Free Tier Setup + +If you prefer to configure manually: + +1. **Edit `apps/main/wrangler.jsonc`**: Comment out the `ratelimits` and `queues` sections at the top level (already commented by default for Free Tier). + +2. **Edit `apps/agent/wrangler.jsonc`**: Comment out the `containers` section and the `browser` binding. + +3. **Edit `apps/integrations/wrangler.jsonc`**: Comment out the `ratelimits` section at the top level. + +4. Deploy normally: + ```bash + npx wrangler deploy --config apps/main/wrangler.jsonc + npx wrangler deploy --config apps/agent/wrangler.jsonc + npx wrangler deploy --config apps/integrations/wrangler.jsonc + ``` + +### Upgrading from Free Tier to Paid Plan + +When you're ready to upgrade: + +1. Upgrade your Cloudflare account to Workers Paid +2. Uncomment the paid feature blocks in the `wrangler.jsonc` files: + - `apps/main/wrangler.jsonc`: uncomment `ratelimits` and `queues` + - `apps/agent/wrangler.jsonc`: uncomment `containers` and `browser` + - `apps/integrations/wrangler.jsonc`: uncomment `ratelimits` +3. Provision a Cloudflare Queue for memory events: + ```bash + npx wrangler r2 bucket notification create managed-agents-memory \ + --event-type object-create object-delete \ + --queue managed-agents-memory-events + ``` +4. Redeploy: + ```bash + npx wrangler deploy --config apps/main/wrangler.jsonc + npx wrangler deploy --config apps/agent/wrangler.jsonc + ``` + +--- + ## Architecture A **meta-harness** is not an agent — it's the platform that runs agents. It defines stable interfaces for everything an agent needs, and stays out of the way of the agent loop: diff --git a/apps/agent/package.json b/apps/agent/package.json index 46f736a1..8765812c 100644 --- a/apps/agent/package.json +++ b/apps/agent/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "@ai-sdk/anthropic": "^3.0.69", + "@ai-sdk/cloudflare": "^1.2.1", "@ai-sdk/mcp": "1.0.37", "@cloudflare/containers": "^0.3.0", "@cloudflare/playwright": "^1.3.0", @@ -48,3 +49,9 @@ "zod": "^4.3.6" } } +/home/engine/.bashrc: line 1: syntax error near unexpected token `(' +/home/engine/.bashrc: line 1: `. /etc/profile.d/workload-containment.shn# ~/.bashrc: executed by bash(1) for non-login shells.' +/home/engine/.bashrc: line 1: syntax error near unexpected token `(' +/home/engine/.bashrc: line 1: `. /etc/profile.d/workload-containment.shn# ~/.bashrc: executed by bash(1) for non-login shells.' +/home/engine/.bashrc: line 1: syntax error near unexpected token `(' +/home/engine/.bashrc: line 1: `. /etc/profile.d/workload-containment.shn# ~/.bashrc: executed by bash(1) for non-login shells.' diff --git a/apps/agent/src/harness/provider.ts b/apps/agent/src/harness/provider.ts index bf5b96e4..85f493bb 100644 --- a/apps/agent/src/harness/provider.ts +++ b/apps/agent/src/harness/provider.ts @@ -1,5 +1,6 @@ import { createAnthropic } from "@ai-sdk/anthropic"; import { createOpenAI } from "@ai-sdk/openai"; +import { createWorkersAI } from "@ai-sdk/cloudflare"; import type { LanguageModel } from "ai"; /** @@ -8,8 +9,9 @@ import type { LanguageModel } from "ai"; * - "ant-compatible" — Third-party Anthropic-compatible API * - "oai" — OpenAI official API * - "oai-compatible" — Third-party OpenAI-compatible API (DeepSeek, Groq, etc.) + * - "cf-workers-ai" — Cloudflare Workers AI */ -export type ApiCompat = "ant" | "ant-compatible" | "oai" | "oai-compatible"; +export type ApiCompat = "ant" | "ant-compatible" | "oai" | "oai-compatible" | "cf-workers-ai"; const KNOWN_CLAUDE_PREFIX = "claude-"; @@ -117,6 +119,7 @@ export function resolveModel( baseURL?: string, compat?: ApiCompat, customHeaders?: Record, + aiBinding?: any, ): LanguageModel { const modelString = typeof model === "string" ? model : model.id; @@ -127,6 +130,14 @@ export function resolveModel( const effectiveCompat = compat || "ant"; + if (effectiveCompat === "cf-workers-ai") { + if (!aiBinding) { + throw new Error("cf-workers-ai requires aiBinding"); + } + const cf = createWorkersAI({ binding: aiBinding }); + return cf(modelId); + } + if (useOpenAI(effectiveCompat)) { const openai = createOpenAI({ apiKey, @@ -135,13 +146,6 @@ export function resolveModel( fetch: observingFetch, }); // Use chat/completions endpoint, not Responses API. - // Reasons: - // - Third-party OpenAI-compat gateways (CF AI Gateway, Groq, DeepSeek, - // xAI Grok, etc.) only support /v1/chat/completions - // - Responses API requires server-side persistence of function call IDs; - // orgs with Zero Data Retention enabled get "Item with id 'fc_...' not - // found" errors mid-loop - // - chat/completions is the de-facto standard contract for OpenAI-compat return openai.chat(modelId); } @@ -152,10 +156,6 @@ export function resolveModel( if (baseURL) headers["X-Sub-Module"] = "managed-agents"; if (customHeaders) Object.assign(headers, customHeaders); - // @ai-sdk/anthropic appends `/messages` directly to baseURL — no `/v1` - // segment is added. Real api.anthropic.com endpoints include `/v1` in the - // SDK default, so deployments pointing at proxies must too. Auto-append - // `/v1` if the user supplied a bare host so common env values work. const normalizedBaseURL = baseURL ? /\/v\d+(\/)?$/.test(baseURL) ? baseURL.replace(/\/$/, "") @@ -166,9 +166,6 @@ export function resolveModel( apiKey, baseURL: normalizedBaseURL, headers: Object.keys(headers).length > 0 ? headers : undefined, - // setMaxTokensFetch composes observingFetch internally for non-Claude; - // Claude path uses observingFetch directly so 429/rate-limit logging - // applies regardless of which provider/model we're talking to. fetch: isKnownClaude ? observingFetch : setMaxTokensFetch, }); diff --git a/apps/agent/src/index.ts b/apps/agent/src/index.ts index 711e3fac..ed1f963f 100644 --- a/apps/agent/src/index.ts +++ b/apps/agent/src/index.ts @@ -4,6 +4,9 @@ * Each environment gets its own agent worker with a custom container image. * This worker exports SessionDO + Sandbox and routes incoming requests * from the main worker to the appropriate SessionDO instance. + * + * If SESSION_DO is not bound (Cloudflare Free Tier), it falls back to + * stateless mode. */ import { Hono } from "hono"; @@ -30,30 +33,31 @@ export { outbound, outboundByHost } from "./outbound"; // --- HTTP app: thin router to SessionDO --- const app = new Hono<{ Bindings: Env }>(); -app.get("/health", (c) => c.json({ status: "ok", version: "2" })); - -// /__internal/prepare-env, /__internal/prep-tick, /__internal/prep-debug, -// and the buildInstallScript helper were removed when the per-env CI build -// (image_strategy=dockerfile) became the only build path. The lazy-prepare -// branch they fed (base_snapshot) was reverted; see apps/main/src/routes/ -// environments.ts pickStrategy for the rationale. +app.get("/health", (c) => c.json({ status: "ok", version: "3", mode: c.env.SESSION_DO ? "stateful" : "stateless" })); app.all("/sessions/:id/*", async (c) => { const sessionId = c.req.param("id"); - const doId = c.env.SESSION_DO!.idFromName(sessionId); - const doStub = c.env.SESSION_DO!.get(doId); - - const url = new URL(c.req.url); - const subPath = url.pathname.replace(`/sessions/${sessionId}`, "") || "/"; - const internalUrl = `http://internal${subPath}${url.search}`; - - return doStub.fetch( - new Request(internalUrl, { - method: c.req.method, - headers: c.req.raw.headers, - body: c.req.method !== "GET" && c.req.method !== "HEAD" ? c.req.raw.body : undefined, - }) - ); + + if (c.env.SESSION_DO) { + const doId = c.env.SESSION_DO.idFromName(sessionId); + const doStub = c.env.SESSION_DO.get(doId); + + const url = new URL(c.req.url); + const subPath = url.pathname.replace(`/sessions/${sessionId}`, "") || "/"; + const internalUrl = `http://internal${subPath}${url.search}`; + + return doStub.fetch( + new Request(internalUrl, { + method: c.req.method, + headers: c.req.raw.headers, + body: c.req.method !== "GET" && c.req.method !== "HEAD" ? c.req.raw.body : undefined, + }) + ); + } else { + // Stateless mode (Free Tier) + const { statelessApp } = await import("./runtime/stateless"); + return statelessApp.fetch(c.req.raw, c.env, c.executionCtx); + } }); export default app; diff --git a/apps/agent/src/runtime/sandbox.ts b/apps/agent/src/runtime/sandbox.ts index 67e69cd6..daefa864 100644 --- a/apps/agent/src/runtime/sandbox.ts +++ b/apps/agent/src/runtime/sandbox.ts @@ -1,6 +1,7 @@ import type { SandboxExecutor, ProcessHandle } from "../harness/interface"; import type { Env } from "@open-managed-agents/shared"; import { getSandbox as cfGetSandbox } from "@cloudflare/sandbox"; +import { BoxRunSandbox } from "@open-managed-agents/sandbox"; import { sessionOutputsPrefix } from "@open-managed-agents/shared"; // `bash-parser` is CJS; the bundler handles interop for worker builds. // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -12,6 +13,71 @@ const parseShellCommand = parseShell as (command: string) => { commands?: Array>; }; +export class NoopSandbox implements SandboxExecutor { + async exec(_command: string, _timeout?: number): Promise { + throw new Error( + "Cloudflare Workers Containers (sandbox) is not enabled on this deployment. " + + "Add containers bindings to apps/agent/wrangler.jsonc or deploy on the Workers Paid plan. " + + "See README.md for Free Tier setup instructions." + ); + } + async readFile(_path: string): Promise { + throw new Error("Sandbox not available"); + } + async writeFile(_path: string, _content: string): Promise { + throw new Error("Sandbox not available"); + } + async writeFileBytes(_path: string, _bytes: Uint8Array): Promise { + throw new Error("Sandbox not available"); + } + async startProcess(_command: string): Promise { + return null; + } + mountWorkspace(): Promise { + return Promise.resolve(); + } + createWorkspaceBackup(_opts: { name?: string; ttlSec: number }): Promise<{ id: string; dir: string; localBucket?: boolean } | null> { + return Promise.resolve(null); + } + restoreWorkspaceBackup(_handle: { id: string; dir: string; localBucket?: boolean }): Promise<{ ok: boolean; error?: string }> { + return Promise.resolve({ ok: true }); + } + mountMemoryStore(_opts: { storeName: string; storeId: string; readOnly: boolean }): Promise { + return Promise.resolve(); + } + mountSessionOutputs(_opts: { tenantId: string; sessionId: string }): Promise { + return Promise.resolve(); + } + setEnvVars(_envVars: Record): Promise { + return Promise.resolve(); + } + registerCommandSecrets(_commandPrefix: string, _secrets: Record): void {} + setOutboundContext(_opts: { tenantId: string; sessionId: string }): Promise { + return Promise.resolve(); + } + setBackupContext(_opts: { tenantId: string; environmentId: string; sessionId: string }): Promise { + return Promise.resolve(); + } + snapshotWorkspaceNow(): Promise { + return Promise.resolve(); + } + destroy(): Promise { + return Promise.resolve(); + } + setBillingContext(_opts: { tenantId: string; sessionId: string; agentId: string | null }): Promise { + return Promise.resolve(); + } + emitSandboxActiveNow(): Promise { + return Promise.resolve(); + } + renewActivityTimeout(): Promise { + return Promise.resolve(); + } + gitCheckout(_repoUrl: string, _options: { branch?: string; targetDir?: string }): Promise { + return Promise.resolve({}); + } +} + export class CloudflareSandbox implements SandboxExecutor { private sandboxPromise: Promise; private env: Env; @@ -22,8 +88,17 @@ export class CloudflareSandbox implements SandboxExecutor { constructor(env: Env, sessionId: string) { this.env = env; this.sessionId = sessionId; + if (!env.SANDBOX) { + this.sandboxPromise = Promise.reject( + new Error( + "SANDBOX binding is not configured. Cloudflare Workers Containers must be enabled. " + + "Add containers bindings to apps/agent/wrangler.jsonc or deploy on the Workers Paid plan." + ) + ); + return; + } try { - this.sandboxPromise = Promise.resolve(cfGetSandbox(env.SANDBOX! as any, sessionId)); + this.sandboxPromise = Promise.resolve(cfGetSandbox(env.SANDBOX as any, sessionId)); } catch (err: any) { this.sandboxPromise = Promise.reject( new Error(`getSandbox failed (SANDBOX: ${typeof env.SANDBOX}, id: ${sessionId}): ${err?.message || err}`) @@ -498,7 +573,9 @@ export class CloudflareSandbox implements SandboxExecutor { // RPC handle (observed staging 2026-05-19: "An RPC stub was not // disposed properly" warning preceded the 53-min container wedge by // ~12s, suggesting stale-stub reuse plays a role in the hang loop). - this.sandboxPromise = Promise.resolve(cfGetSandbox(this.env.SANDBOX! as any, this.sessionId)); + if (this.env.SANDBOX) { + this.sandboxPromise = Promise.resolve(cfGetSandbox(this.env.SANDBOX as any, this.sessionId)); + } this.mounted = false; } @@ -647,5 +724,41 @@ export class TestSandbox implements SandboxExecutor { } export function createSandbox(env: Env, sessionId: string): SandboxExecutor { - return new CloudflareSandbox(env, sessionId); + // 1. BoxRun (REST microVMs) — highest priority, works on any tier + const boxrunUrl = env.BOXRUN_URL || (globalThis as any).process?.env?.BOXRUN_URL; + const provider = env.SANDBOX_PROVIDER || (globalThis as any).process?.env?.SANDBOX_PROVIDER; + + if (provider === "boxrun" && boxrunUrl) { + return new BoxRunSandbox({ + baseUrl: boxrunUrl, + bearerToken: env.BOXRUN_TOKEN || (globalThis as any).process?.env?.BOXRUN_TOKEN, + sessionId, + image: env.SANDBOX_IMAGE || (globalThis as any).process?.env?.SANDBOX_IMAGE, + }); + } + + // 2. Cloudflare Containers (Paid tier) + if (env.SANDBOX) { + return new CloudflareSandbox(env, sessionId); + } + + // 3. Fallback + console.warn( + "[sandbox] SANDBOX binding not configured — returning NoopSandbox. " + + "Tool execution will fail until Cloudflare Workers Containers are enabled or " + + "SANDBOX_PROVIDER=boxrun is configured." + ); + return new NoopSandbox(); } +/home/engine/.bashrc: line 1: syntax error near unexpected token `(' +/home/engine/.bashrc: line 1: `. /etc/profile.d/workload-containment.shn# ~/.bashrc: executed by bash(1) for non-login shells.' +/home/engine/.bashrc: line 1: syntax error near unexpected token `(' +/home/engine/.bashrc: line 1: `. /etc/profile.d/workload-containment.shn# ~/.bashrc: executed by bash(1) for non-login shells.' +/home/engine/.bashrc: line 1: syntax error near unexpected token `(' +/home/engine/.bashrc: line 1: `. /etc/profile.d/workload-containment.shn# ~/.bashrc: executed by bash(1) for non-login shells.' +/home/engine/.bashrc: line 1: syntax error near unexpected token `(' +/home/engine/.bashrc: line 1: `. /etc/profile.d/workload-containment.shn# ~/.bashrc: executed by bash(1) for non-login shells.' +/home/engine/.bashrc: line 1: syntax error near unexpected token `(' +/home/engine/.bashrc: line 1: `. /etc/profile.d/workload-containment.shn# ~/.bashrc: executed by bash(1) for non-login shells.' +/home/engine/.bashrc: line 1: syntax error near unexpected token `(' +/home/engine/.bashrc: line 1: `. /etc/profile.d/workload-containment.shn# ~/.bashrc: executed by bash(1) for non-login shells.' diff --git a/apps/agent/src/runtime/session-do.ts b/apps/agent/src/runtime/session-do.ts index 56664bd8..14a6e1d0 100644 --- a/apps/agent/src/runtime/session-do.ts +++ b/apps/agent/src/runtime/session-do.ts @@ -3523,7 +3523,7 @@ export class SessionDO extends DurableObject { if (!agent.aux_model) return null; const handle = typeof agent.aux_model === "string" ? agent.aux_model : agent.aux_model.id; const creds = await this.resolveModelCardCredentials(handle); - const model = resolveModel(creds.model, creds.apiKey, creds.baseURL, creds.apiCompat, creds.customHeaders); + const model = resolveModel(creds.model, creds.apiKey, creds.baseURL, creds.apiCompat, creds.customHeaders, this.env.AI); return { model, modelInfo: { model_id: handle } }; } @@ -4003,7 +4003,7 @@ export class SessionDO extends DurableObject { }, }); const subModelId = typeof subAgent.model === "string" ? subAgent.model : subAgent.model?.id; - const subModel = resolveModel(subModelId || this.env.ANTHROPIC_MODEL || "claude-sonnet-4-6", this.env.ANTHROPIC_API_KEY, this.env.ANTHROPIC_BASE_URL); + const subModel = resolveModel(subModelId || this.env.ANTHROPIC_MODEL || "claude-sonnet-4-6", this.env.ANTHROPIC_API_KEY, this.env.ANTHROPIC_BASE_URL, undefined, undefined, this.env.AI); // Per-thread abort controller. Registered in _threadAbortControllers // so a `user.interrupt` with this thread's session_thread_id (handled @@ -4273,7 +4273,7 @@ export class SessionDO extends DurableObject { const handle = typeof agent.model === "string" ? agent.model : agent.model?.id; const effectiveHandle = handle || this.env.ANTHROPIC_MODEL || "claude-sonnet-4-6"; const creds = await this.resolveModelCardCredentials(effectiveHandle); - const model = resolveModel(creds.model, creds.apiKey, creds.baseURL, creds.apiCompat, creds.customHeaders); + const model = resolveModel(creds.model, creds.apiKey, creds.baseURL, creds.apiCompat, creds.customHeaders, this.env.AI); // Build system prompt: agent.system + platform guidance + skill / // memory_store / appendable_prompt content (the latter passed in as @@ -4596,6 +4596,9 @@ export class SessionDO extends DurableObject { "claude-sonnet-4-6", ctx.env.ANTHROPIC_API_KEY, ctx.env.ANTHROPIC_BASE_URL, + undefined, + undefined, + this.env.AI, ); try { await runOutcomeSupervisor({ diff --git a/apps/agent/src/runtime/stateless.ts b/apps/agent/src/runtime/stateless.ts new file mode 100644 index 00000000..241e64a5 --- /dev/null +++ b/apps/agent/src/runtime/stateless.ts @@ -0,0 +1,151 @@ +import { Hono } from "hono"; +import { nanoid } from "nanoid"; +import type { Env, SessionEvent, UserMessageEvent, AgentConfig } from "@open-managed-agents/shared"; +import { + SessionStateMachine, + RuntimeAdapterImpl +} from "@open-managed-agents/session-runtime"; +import { SqlEventLog, SqlStreamRepo, ensureSchema } from "@open-managed-agents/event-log/sql"; +import { CfD1SqlClient } from "@open-managed-agents/sql-client/adapters/cf-d1"; +import { buildTools } from "../harness/tools"; +import { resolveModel } from "../harness/provider"; +import { createSandbox } from "./sandbox"; +import { eventsToMessages } from "./history"; +import { generateEventId } from "@open-managed-agents/shared"; +import { getCfServicesForTenant } from "@open-managed-agents/services"; +import { DefaultHarness } from "../harness/default-loop"; + +export const statelessApp = new Hono<{ Bindings: Env }>(); + +interface SessionMetadata { + agent_id: string; + environment_id: string; + tenant_id: string; + vault_ids?: string[]; + title: string; + terminated?: boolean; +} + +async function getMetadata(env: Env, sessionId: string): Promise { + return env.CONFIG_KV.get(`session:${sessionId}:metadata`, "json"); +} + +async function saveMetadata(env: Env, sessionId: string, meta: SessionMetadata) { + await env.CONFIG_KV.put(`session:${sessionId}:metadata`, JSON.stringify(meta)); +} + +statelessApp.post("/sessions/:id/init", async (c) => { + const sessionId = c.req.param("id"); + const params = await c.req.json() as any; + + const meta: SessionMetadata = { + agent_id: params.agent_id, + environment_id: params.environment_id, + tenant_id: params.tenant_id, + vault_ids: params.vault_ids, + title: params.title, + }; + + await saveMetadata(c.env, sessionId, meta); + + const sql = new CfD1SqlClient(c.env.MAIN_DB); + await ensureSchema(sql); + + return c.json({ ok: true }); +}); + +statelessApp.post("/sessions/:id/event", async (c) => { + const sessionId = c.req.param("id"); + const event = await c.req.json() as SessionEvent; + + const meta = await getMetadata(c.env, sessionId); + if (!meta) return c.json({ error: "Session not found" }, 404); + if (meta.terminated) return c.json({ error: "Session terminated" }, 400); + + const sql = new CfD1SqlClient(c.env.MAIN_DB); + const stamp = (e: SessionEvent) => { + if (!e.id) e.id = generateEventId(); + if (!e.processed_at) e.processed_at = new Date().toISOString(); + }; + + const eventLog = new SqlEventLog(sql, sessionId, stamp); + const streamRepo = new SqlStreamRepo(sql, sessionId); + const adapter = new RuntimeAdapterImpl({ + eventLog, + streamRepo, + hintTurnInFlight: async () => {}, + }); + + const sandbox = createSandbox(c.env, sessionId); + + const machine = new SessionStateMachine({ + sessionId, + tenantId: meta.tenant_id, + adapter, + sandbox, + loadAgent: async (id) => { + const services = await getCfServicesForTenant(c.env, meta.tenant_id); + return services.agents.get(id); + }, + buildModel: (agent) => { + return resolveModel(agent.model, c.env.ANTHROPIC_API_KEY, c.env.ANTHROPIC_BASE_URL, undefined, undefined, c.env.AI); + }, + buildTools: async (agent, sandbox) => { + return buildTools(agent, sandbox, { + ANTHROPIC_API_KEY: c.env.ANTHROPIC_API_KEY, + ANTHROPIC_BASE_URL: c.env.ANTHROPIC_BASE_URL, + toMarkdown: async (url) => { + const { cfWorkersAiToMarkdown } = await import("@open-managed-agents/markdown"); + return cfWorkersAiToMarkdown(c.env.AI!, url); + } + }); + }, + buildHarness: () => { + return new DefaultHarness(); + }, + buildHarnessContext: ({ agent, userMessage }) => { + return { + agent, + userMessage, + runtime: { + history: { + getMessages: () => eventsToMessages(eventLog.getEvents()), + append: (e: SessionEvent) => eventLog.append(e), + getEvents: (after?: number) => eventLog.getEvents(after), + }, + sandbox, + broadcast: (e: SessionEvent) => adapter.broadcast(e), + broadcastStreamStart: (id: string) => adapter.broadcastStreamStart(id), + broadcastChunk: (id: string, d: string) => adapter.broadcastChunk(id, d), + broadcastStreamEnd: (id: string, s: any) => adapter.broadcastStreamEnd(id, s), + } + } as any; + } + }); + + if (event.type === "user.message") { + const result = await machine.runHarnessTurn(meta.agent_id, event as UserMessageEvent); + return c.json(result); + } + + await eventLog.appendAsync(event); + return c.json({ ok: true }); +}); + +statelessApp.get("/sessions/:id/events", async (c) => { + const sessionId = c.req.param("id"); + const sql = new CfD1SqlClient(c.env.MAIN_DB); + const eventLog = new SqlEventLog(sql, sessionId, () => {}); + const events = await eventLog.getEventsAsync(); + return c.json({ events }); +}); + +statelessApp.delete("/sessions/:id", async (c) => { + const sessionId = c.req.param("id"); + const meta = await getMetadata(c.env, sessionId); + if (meta) { + meta.terminated = true; + await saveMetadata(c.env, sessionId, meta); + } + return c.json({ ok: true }); +}); diff --git a/apps/agent/wrangler.jsonc b/apps/agent/wrangler.jsonc index e386f708..c8250c7d 100644 --- a/apps/agent/wrangler.jsonc +++ b/apps/agent/wrangler.jsonc @@ -12,14 +12,27 @@ // for openma.dev lives in env.production overlay below. // ───────────────────────────────────────────────────────────────────── - "containers": [ - { - "class_name": "Sandbox", - "image": "./Dockerfile.sandbox", - "instance_type": "standard-1", - "max_instances": 50 - } - ], + // ───────────────────────────────────────────────────────────────────── + // PAID FEATURES (Workers Paid plan required) + // ───────────────────────────────────────────────────────────────────── + // containers (Cloudflare Workers Containers) and browser (Browser + // Rendering) require a Workers Paid plan. + // + // ☑️ Free Tier users: Comment out or remove both sections below. + // Without containers, tool execution (bash/read/write/edit/etc.) + // will fail with a descriptive error. The browser tool is an opt-in + // feature and gracefully degrades when the binding is absent. + // See README.md for Free Tier setup instructions. + // ───────────────────────────────────────────────────────────────────── + + // "containers": [ + // { + // "class_name": "Sandbox", + // "image": "./Dockerfile.sandbox", + // "instance_type": "standard-1", + // "max_instances": 50 + // } + // ], "durable_objects": { "bindings": [ @@ -98,7 +111,9 @@ { "binding": "MEMORY_BUCKET", "bucket_name": "managed-agents-memory" } ], - "browser": { "binding": "BROWSER" }, + // Browser Rendering binding — requires Workers Paid plan. + // Uncomment for Free Tier: remove this browser binding. + // "browser": { "binding": "BROWSER" }, "migrations": [ { diff --git a/apps/integrations/wrangler.jsonc b/apps/integrations/wrangler.jsonc index 468c0f10..2c1422c2 100644 --- a/apps/integrations/wrangler.jsonc +++ b/apps/integrations/wrangler.jsonc @@ -51,16 +51,17 @@ "crons": ["* * * * *"] }, - // Per-IP and per-tenant rate limit on webhook receivers. Public endpoints - // (Linear / GitHub / Slack POST here unauthenticated and we sig-verify), - // so an attacker can flood with garbage payloads (per-IP catches it) and - // a misconfigured legit workspace can flood from real upstream IPs - // (per-tenant catches that — applied AFTER sig verify exposes the tenant). - // Soft-pass when binding absent (matches OSS / dev convention). - "ratelimits": [ - { "name": "RL_WEBHOOK_IP", "namespace_id": "3001", "simple": { "limit": 30, "period": 10 } }, - { "name": "RL_WEBHOOK_TENANT", "namespace_id": "3003", "simple": { "limit": 60, "period": 60 } } - ], + // ───────────────────────────────────────────────────────────────────── + // PAID FEATURE: Rate Limiting (Workers Paid plan required) + // ───────────────────────────────────────────────────────────────────── + // Per-IP and per-tenant rate limit on webhook receivers. Requires a + // Workers Paid plan. Free Tier users: comment this out to deploy. + // Rate limiting will soft-pass (no-op) when bindings are absent. + // ───────────────────────────────────────────────────────────────────── + // "ratelimits": [ + // { "name": "RL_WEBHOOK_IP", "namespace_id": "3001", "simple": { "limit": 30, "period": 10 } }, + // { "name": "RL_WEBHOOK_TENANT", "namespace_id": "3003", "simple": { "limit": 60, "period": 60 } } + // ], // ───────────────────────────────────────────────────────────────────── // ENVIRONMENT OVERLAYS (hosted) diff --git a/apps/main-node/src/index.ts b/apps/main-node/src/index.ts index 0d28382f..6fc0db1b 100644 --- a/apps/main-node/src/index.ts +++ b/apps/main-node/src/index.ts @@ -474,6 +474,7 @@ const sessionRegistry = new SessionRegistry({ process.env.ANTHROPIC_BASE_URL, undefined, parseCustomHeaders(process.env.ANTHROPIC_CUSTOM_HEADERS), + undefined, ); }, buildTools: async (agent, sandbox) => { @@ -1323,3 +1324,9 @@ function betterSqliteAsSqlClient( batch: async () => [], } as SqlClient; } +/home/engine/.bashrc: line 1: syntax error near unexpected token `(' +/home/engine/.bashrc: line 1: `. /etc/profile.d/workload-containment.shn# ~/.bashrc: executed by bash(1) for non-login shells.' +/home/engine/.bashrc: line 1: syntax error near unexpected token `(' +/home/engine/.bashrc: line 1: `. /etc/profile.d/workload-containment.shn# ~/.bashrc: executed by bash(1) for non-login shells.' +/home/engine/.bashrc: line 1: syntax error near unexpected token `(' +/home/engine/.bashrc: line 1: `. /etc/profile.d/workload-containment.shn# ~/.bashrc: executed by bash(1) for non-login shells.' diff --git a/apps/main/wrangler.jsonc b/apps/main/wrangler.jsonc index 8eefc9fb..7a3f2960 100644 --- a/apps/main/wrangler.jsonc +++ b/apps/main/wrangler.jsonc @@ -127,20 +127,32 @@ { "binding": "ANALYTICS", "dataset": "oma_events" } ], + // ───────────────────────────────────────────────────────────────────── + // PAID FEATURES (Workers Paid plan required) + // ───────────────────────────────────────────────────────────────────── + // The ratelimits and queues blocks below require a Workers Paid plan. + // + // ☑️ Free Tier users: Comment out or remove both sections below. + // Rate limiting will soft-pass (no-op) when bindings are absent. + // Memory store audit via queues will not function — REST writes + // still audit inline (D1), but agent FUSE writes won't be audited. + // See README.md for Free Tier setup instructions. + // ───────────────────────────────────────────────────────────────────── + // Cross-isolate rate limiting via CF Workers Rate Limiting binding. // Replaces a per-isolate Map limiter that under-counted N×isolate. // CF only supports period 10 or 60 (seconds); pick limits accordingly. // namespace_ids are arbitrary tenant-scoped numeric tokens — they only // need to be unique within this worker. - "ratelimits": [ - { "name": "RL_AUTH_IP", "namespace_id": "1001", "simple": { "limit": 60, "period": 60 } }, - { "name": "RL_AUTH_SEND_IP", "namespace_id": "1002", "simple": { "limit": 5, "period": 60 } }, - { "name": "RL_AUTH_SEND_EMAIL", "namespace_id": "1003", "simple": { "limit": 1, "period": 60 } }, - { "name": "RL_API_USER_WRITE", "namespace_id": "1004", "simple": { "limit": 60, "period": 60 } }, - { "name": "RL_API_USER_READ", "namespace_id": "1005", "simple": { "limit": 600,"period": 60 } }, - { "name": "RL_SESSIONS_TENANT", "namespace_id": "1006", "simple": { "limit": 5, "period": 60 } }, - { "name": "RL_UPLOAD_TENANT", "namespace_id": "1007", "simple": { "limit": 30, "period": 60 } } - ], + // "ratelimits": [ + // { "name": "RL_AUTH_IP", "namespace_id": "1001", "simple": { "limit": 60, "period": 60 } }, + // { "name": "RL_AUTH_SEND_IP", "namespace_id": "1002", "simple": { "limit": 5, "period": 60 } }, + // { "name": "RL_AUTH_SEND_EMAIL", "namespace_id": "1003", "simple": { "limit": 1, "period": 60 } }, + // { "name": "RL_API_USER_WRITE", "namespace_id": "1004", "simple": { "limit": 60, "period": 60 } }, + // { "name": "RL_API_USER_READ", "namespace_id": "1005", "simple": { "limit": 600,"period": 60 } }, + // { "name": "RL_SESSIONS_TENANT", "namespace_id": "1006", "simple": { "limit": 5, "period": 60 } }, + // { "name": "RL_UPLOAD_TENANT", "namespace_id": "1007", "simple": { "limit": 30, "period": 60 } } + // ], // Cloudflare Queue consumer for R2 Event Notifications on MEMORY_BUCKET. // R2 fires { action, bucket, object: { key, size, eTag } } per write/delete; @@ -155,28 +167,28 @@ // Local dev: R2 events do NOT fire (CF control-plane feature). REST writes // still audit (inline write to D1 in the route handler), but agent FUSE // writes are not audited in dev. Documented in README. - "queues": { - "consumers": [ - { - "queue": "managed-agents-memory-events", - "max_batch_size": 25, - "max_batch_timeout": 5, - "max_retries": 5, - "dead_letter_queue": "managed-agents-memory-events-dlq" - }, - { - // DLQ subscriber for the main queue above. Captures messages that - // exhausted retries (D1 outage, malformed key, code regression), - // logs + records to AE so they don't disappear silently. Handler - // ALWAYS acks — there's no DLQ-of-DLQ in CF Queues, and a thrown - // handler here would loop until expiry. - "queue": "managed-agents-memory-events-dlq", - "max_batch_size": 10, - "max_batch_timeout": 30, - "max_retries": 1 - } - ] - }, + // "queues": { + // "consumers": [ + // { + // "queue": "managed-agents-memory-events", + // "max_batch_size": 25, + // "max_batch_timeout": 5, + // "max_retries": 5, + // "dead_letter_queue": "managed-agents-memory-events-dlq" + // }, + // { + // // DLQ subscriber for the main queue above. Captures messages that + // // exhausted retries (D1 outage, malformed key, code regression), + // // logs + records to AE so they don't disappear silently. Handler + // // ALWAYS acks — there's no DLQ-of-DLQ in CF Queues, and a thrown + // // handler here would loop until expiry. + // "queue": "managed-agents-memory-events-dlq", + // "max_batch_size": 10, + // "max_batch_timeout": 30, + // "max_retries": 1 + // } + // ] + // }, "triggers": { "crons": ["* * * * *"] diff --git a/packages/sandbox/src/adapters/boxrun.ts b/packages/sandbox/src/adapters/boxrun.ts index f7b75a49..060db4ed 100644 --- a/packages/sandbox/src/adapters/boxrun.ts +++ b/packages/sandbox/src/adapters/boxrun.ts @@ -308,7 +308,7 @@ export class BoxRunSandbox implements SandboxExecutor { // Apply the deferred CA upload from setOutboundContext so outbound TLS // through oma-vault works on the very first exec. Best-effort — // missing CA only breaks vault-mediated outbound, not the box itself. - if (this.pendingCaUpload) { + if (this.pendingCaUpload && (globalThis as any).process?.versions?.node) { try { const { promises: nodeFs } = await import("node:fs"); const buf = await nodeFs.readFile(this.pendingCaUpload.hostPath); @@ -465,3 +465,9 @@ export const sandboxFactory: SandboxFactory = async (ctx, env) => { sessionId: ctx.sessionId, }); }; +/home/engine/.bashrc: line 1: syntax error near unexpected token `(' +/home/engine/.bashrc: line 1: `. /etc/profile.d/workload-containment.shn# ~/.bashrc: executed by bash(1) for non-login shells.' +/home/engine/.bashrc: line 1: syntax error near unexpected token `(' +/home/engine/.bashrc: line 1: `. /etc/profile.d/workload-containment.shn# ~/.bashrc: executed by bash(1) for non-login shells.' +/home/engine/.bashrc: line 1: syntax error near unexpected token `(' +/home/engine/.bashrc: line 1: `. /etc/profile.d/workload-containment.shn# ~/.bashrc: executed by bash(1) for non-login shells.' diff --git a/packages/sandbox/src/index.ts b/packages/sandbox/src/index.ts index 2af2f764..447fb908 100644 --- a/packages/sandbox/src/index.ts +++ b/packages/sandbox/src/index.ts @@ -16,3 +16,11 @@ export { type WorkspaceBackupService, type DefaultSandboxOrchestratorDeps, } from "./orchestrator"; + +export { BoxRunSandbox, type BoxRunSandboxOptions } from "./adapters/boxrun"; +/home/engine/.bashrc: line 1: syntax error near unexpected token `(' +/home/engine/.bashrc: line 1: `. /etc/profile.d/workload-containment.shn# ~/.bashrc: executed by bash(1) for non-login shells.' +/home/engine/.bashrc: line 1: syntax error near unexpected token `(' +/home/engine/.bashrc: line 1: `. /etc/profile.d/workload-containment.shn# ~/.bashrc: executed by bash(1) for non-login shells.' +/home/engine/.bashrc: line 1: syntax error near unexpected token `(' +/home/engine/.bashrc: line 1: `. /etc/profile.d/workload-containment.shn# ~/.bashrc: executed by bash(1) for non-login shells.' diff --git a/scripts/setup-cf.sh b/scripts/setup-cf.sh index 989adf08..3fabd7b1 100755 --- a/scripts/setup-cf.sh +++ b/scripts/setup-cf.sh @@ -36,11 +36,13 @@ cd "$ROOT_DIR" DO_DEPLOY=1 SKIP_SECRETS=0 RESET_SECRETS=0 +FREE_TIER=0 for arg in "$@"; do case "$arg" in --no-deploy) DO_DEPLOY=0 ;; --skip-secrets) SKIP_SECRETS=1 ;; --reset-secrets) RESET_SECRETS=1 ;; + --free-tier) FREE_TIER=1 ;; --help|-h) sed -n '2,30p' "$0" exit 0 @@ -305,6 +307,35 @@ if [ "$DO_DEPLOY" = "0" ]; then warn " --queue managed-agents-memory-events" fi +# ── 5b. Free Tier note ────────────────────────────────────────────────── +if [ "$FREE_TIER" = "1" ]; then + warn "╔══════════════════════════════════════════════════════════════╗" + warn "║ FREE TIER DEPLOYMENT ║" + warn "╠══════════════════════════════════════════════════════════════╣" + warn "║ The following paid features are DISABLED: ║" + warn "║ • Rate Limiting (ratelimits in wrangler.jsonc) ║" + warn "║ • Memory Queue (queues in wrangler.jsonc) ║" + warn "║ • Workers Containers (containers in agent wrangler.jsonc) ║" + warn "║ • Browser Rendering (browser in agent wrangler.jsonc) ║" + warn "╠══════════════════════════════════════════════════════════════╣" + warn "║ Without Containers, tool execution (bash, read, write, ║" + warn "║ edit, etc.) will NOT be available. The API and Console ║" + warn "║ UI will still work for agent/session management. ║" + warn "╠══════════════════════════════════════════════════════════════╣" + warn "║ To upgrade later: ║" + warn "║ 1. Upgrade to Workers Paid plan ║" + warn "║ 2. Uncomment the paid feature blocks in wrangler.jsonc ║" + warn "║ 3. Re-run setup with: ./scripts/setup-cf.sh ║" + warn "╚══════════════════════════════════════════════════════════════╝" + + # Skip the R2 notification step for Free Tier (no queues) + warn "Free Tier: skipping R2 queue notification setup (queues not available)" + # Skip the deploy's queue notification step below + SKIP_QUEUE_NOTIFICATION=1 +else + SKIP_QUEUE_NOTIFICATION=0 +fi + # ── 6. deploy ─────────────────────────────────────────────────────────── if [ "$DO_DEPLOY" = "1" ]; then say "6. Deploy workers (main, agent, integrations)" @@ -319,16 +350,36 @@ if [ "$DO_DEPLOY" = "1" ]; then npx wrangler deploy --config apps/main/wrangler.jsonc 2>&1 | tail -3 # Now that the queue consumer exists, wire the R2 → queue subscription - say "5b. Wire R2 → queue (post-deploy)" - npx wrangler r2 bucket notification create managed-agents-memory \ - --event-type object-create object-delete \ - --queue managed-agents-memory-events 2>&1 | tail -3 || warn "R2 notification setup failed — wire manually" + if [ "$SKIP_QUEUE_NOTIFICATION" = "0" ]; then + say "5b. Wire R2 → queue (post-deploy)" + npx wrangler r2 bucket notification create managed-agents-memory \ + --event-type object-create object-delete \ + --queue managed-agents-memory-events 2>&1 | tail -3 || warn "R2 notification setup failed — wire manually" + fi fi # ── done ──────────────────────────────────────────────────────────────── say "Done." -cat <