# Gadgets and sandboxing

> How a gadget is isolated: a Dynamic Worker loaded through the `LOADER` binding with internet access disabled, a sandboxed client iframe restricted by Content-Security-Policy, and a Cap'n Web session bridged over postMessage. Covers per-gadget Durable Object storage, code versions, and the `global_fetch_strictly_public` SSRF posture.

- Repository: cloudflare/cloudflare-os
- GitHub: https://github.com/cloudflare/cloudflare-os
- Human docs: https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd
- Complete Markdown: https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/llms-full.txt

## Source Files

- `packages/workshop-backend/src/overseer.ts`
- `packages/workshop-backend/wrangler.jsonc`
- `packages/workshop-backend/src/web-fetch.ts`
- `packages/workshop-shared/src/api.ts`
- `AGENTS.md`

---

---
title: "Gadgets and sandboxing"
description: "How a gadget is isolated: a Dynamic Worker loaded through the `LOADER` binding with internet access disabled, a sandboxed client iframe restricted by Content-Security-Policy, and a Cap'n Web session bridged over postMessage. Covers per-gadget Durable Object storage, code versions, and the `global_fetch_strictly_public` SSRF posture."
---

A gadget is isolated on two sides at once. Server-side, gadget code runs as a Dynamic Worker loaded through the `LOADER` worker-loader binding declared in `packages/workshop-backend/wrangler.jsonc`, wrapped in the `CODE_MODE_HARNESS` module defined in `packages/workshop-backend/src/overseer.ts`. Client-side, the gadget UI runs in a sandboxed iframe that, per the contract documented at the top of `packages/workshop-shared/src/api.ts`, "has no ability to talk to the outside world at all, except `postMessage()` to the parent frame" — and through that channel it speaks Cap'n Web RPC back to the Workshop, which hands it a stub pointing at the gadget's server-side Durable Object interface.

## The two sandboxes

```text
  ┌──── browser ─────────────────────────────────┐   ┌──── Workers ──────────────────────────────┐
  │                                              │   │                                           │
  │  workshop-frontend (SPA, React + Vite)       │   │  workshop-backend (the "kernel")          │
  │    │  persistent WebSocket, Cap'n Web        │   │    OverseerDurableObject  ── ctx.exports  │
  │    ├────────────── /api ───────────────────────────▶  UserDurableObject                      │
  │    │                                         │   │      │                                    │
  │    ▼  postMessage() is the ONLY exit         │   │      │ env.LOADER (worker_loaders)         │
  │  ┌────────────────────────────┐              │   │      ▼                                    │
  │  │ sandboxed gadget iframe    │              │   │  Dynamic Worker                            │
  │  │  - CSP-restricted          │◀── stub ─────────▶    CODE_MODE_HARNESS + agent.js            │
  │  │  - no network of its own   │   (DO iface) │   │    globalOutbound disabled                 │
  │  └────────────────────────────┘              │   │                                           │
  └──────────────────────────────────────────────┘   └───────────────────────────────────────────┘
```

Both halves are deliberate: the frontend is a pure client-side SPA precisely *because* "the Gadgets themselves are sandboxed on the client side in addition to the server side," and that sandboxing requires running code in the browser — a gadget cannot be server-side rendered.

## Server side: the Dynamic Worker

Gadget code is not deployed as a Worker script. It is loaded at request time through `env.LOADER`, declared as a `worker_loaders` binding:

```jsonc title="packages/workshop-backend/wrangler.jsonc"
"worker_loaders": [
  { "binding": "LOADER" }
]
```

The loaded module is not the gadget's file directly. `overseer.ts` wraps it in a fixed harness module that imports the gadget's `agent.js` and exposes a `WorkerEntrypoint`:

```js title="CODE_MODE_HARNESS (packages/workshop-backend/src/overseer.ts)"
import { WorkerEntrypoint, restore, RpcStub, RpcTarget } from "cloudflare:workers";
import agent from "agent.js";

export default class extends WorkerEntrypoint {
  verify() {}
  async run(self, callbackResolvers) {
    let env = this.env;
    if (callbackResolvers) {
      for (let [index, {resolve, reject}] of Object.entries(callbackResolvers)) {
        env[index] = { args: env[index], resolve, reject };
      }
    }
    await agent(self, env, this.ctx);
  }
  [restore](params) { /* placeholder stub for persistent hook callbacks */ }
}
```

The corresponding TypeScript view of the loaded entrypoint is `CodeModeEntrypoint`:

<ResponseField name="verify()" type="() => void">
Cheap liveness/compile check on the loaded module. Called with no arguments.
</ResponseField>

<ResponseField name="run(self?, callbackResolvers?)" type="Promise<void>">
Invokes the gadget's default export as `agent(self, env, this.ctx)`. `callbackResolvers` is a record keyed by binding index, each holding native `RpcStub` values for `resolve` and `reject`; the harness folds each one into `env[index]` as `{ args, resolve, reject }` before the gadget runs.
</ResponseField>

### The `[restore]` placeholder

The harness implements `[restore]` from `cloudflare:workers`, but not to return a live target. Per the comment in the harness, the runtime does not yet let the backend invoke the gadget's own `[restore]()` to produce the real target stub, so `[restore]` returns a `PlaceholderRpcTarget` — a `Proxy` that returns `undefined` for `then` and `dup` and throws on every other property access:

> "Tried to invoke a placeholder stub for a persistent hook callback. This stub is only intended to be stored; once loaded back from storage it will work properly."

<Warning>
This is explicitly a temporary hack pending runtime APIs for sealing/unsealing. The placeholder is only safe because such stubs are constructed to be handed to `bindHook()` and stored; once read back from storage the stub has been replaced with the real thing. Calling one before it round-trips through storage throws.
</Warning>

Stub storage is enabled by the `allow_irrevocable_stub_storage` compatibility flag in `wrangler.jsonc`, alongside `enhanced_error_serialization` and `nodejs_compat`.

## Client side: the sandboxed iframe

The gadget UI runs inside a sandboxed iframe with no route to the outside world other than `postMessage()` to its parent frame. That single channel is used to carry Cap'n Web RPC exchanges between the gadget and the Workshop, and one of the capabilities the Workshop passes across it is a stub for the gadget's own server-side Durable Object interface.

The RPC protocol is Cap'n Web (`capnweb`), chosen because it has semantics similar to Cloudflare's Worker-to-Worker RPC while being able to run in a browser over WebSocket. The client's own link to the backend is a separate persistent WebSocket to `/api`, opened at startup and kept open for the whole session, reconnecting as needed.

<Note>
Because the gadget frame has no network of its own, every effect a gadget has — storage, connector access, outbound fetches — is mediated by a capability handed to it over `postMessage`. A capability the gadget was never given is a capability it cannot reach.
</Note>

## Capability posture around gadget bindings

The kernel rules in `AGENTS.md` constrain how a gadget acquires capabilities:

| Rule | Consequence for a gadget |
| --- | --- |
| A resource becomes "ambient" (auto-injected) only by user or admin configuration | A gatekeeper must never assert its own ambience into a gadget's env |
| Ambient singletons are folded into each chat's env as a named chat binding | Named by the gatekeeper's `suggestedBindingName`; see `prepareChatBindings` in `overseer.ts` |
| Ambient singletons are not bound to any gadget by default | Most gadgets never call one programmatically; the agent must wire it in with `setGadgetBinding` when the gadget's persistent code needs it |
| The account capability, not an asserted identity, is the authority | Auto-provisioned accounts are persisted in the user DO like any connected account |

Binding names are validated before they can appear as `env.NAME` in gadget code. `workshop-shared/src/api.ts` restricts them with `IDENTIFIER_REGEX` (`/^[A-Za-z_][A-Za-z0-9_]*$/`) and a `RESERVED_WORDS` set:

- `$` is legal in JavaScript identifiers but deliberately excluded — it is conventionally reserved for code generators, so agents should not use it.
- Full Unicode identifiers buy nothing, since binding names are typed by agents and rendered as `env.NAME`.
- ECMAScript reserved words (`class`, `default`, `import`, `false`, …) pass the regex but cannot follow `.` in all contexts, so they are rejected separately.

## Durable Object storage and DO topology

The backend's Durable Object classes are declared through migrations in `wrangler.jsonc` and reached without an explicit `durable_objects` binding:

:::updates

@update v0 - `new_sqlite_classes: ["UserDurableObject", "OverseerDurableObject"]`

@update v1 - `new_sqlite_classes: ["AdminSettings"]`

@update v2 - `new_sqlite_classes: ["PendingLogin"]` — sign-in via authentication gatekeepers: a short-lived `PendingLogin` DO bridges each gatekeeper login back to the waiting browser.

:::

> All DO classes (`UserDurableObject`, `OverseerDurableObject`, `AdminSettings`, `PendingLogin`, …) are reached via `ctx.exports` and need no explicit `durable_objects` binding.

All classes are SQLite-backed. `overseer.ts` builds its storage layer with `createTypedStorage`, `collection`, and `keyString` from `@gadgets/typed-storage`, and uses `yjs` for collaborative file state — the same package also holds the per-chat compaction checkpoint keys (the `chatCompactions` collection) and `Y`-rooted file trees. `WorkpieceId` in `workshop-shared/src/api.ts` is the identifier that ties these together:

<ResponseField name="WorkpieceId" type="number">
A numbered thing the user or agent is working on inside a workspace — currently a gadget or a gatekeeper (connection), with more types expected later. All workpiece types share **one sequential per-workspace ID namespace**, so a bare number unambiguously identifies a workpiece of any type, and derived names (Yjs file roots, facet names) can never collide across types.
</ResponseField>

Beyond DO storage, the backend holds gadget/blueprint artifacts in `BLUEPRINTS` and `AVATARS` KV namespaces, the `BLUEPRINT_CONTENT` R2 bucket, and a `BROWSER` binding (used for gadget PDF export via `renderGadgetPdf` in `browser-export.ts`).

## Live chat state and agent callbacks

While an agent is running against a gadget, `overseer.ts` keeps per-chat in-memory state rather than persisting transient stubs:

```ts title="packages/workshop-backend/src/overseer.ts"
type LiveChatContext = {
  cancelController: AbortController;
  pendingAgentCallbacks: QueuedAgentCallback[];
  activeAgentCallbacks: Map<number, {
    transientStubs: any[];
    resolve: (v: unknown) => void;
    reject: (e: unknown) => void;
  }>;
};
```

`activeAgentCallbacks` is keyed by message sequence number, and its transient RPC stubs live only until the `deliverAgentCallback` RPC returns. A callback arriving while the agent is running is queued as a `QueuedAgentCallback` (`methodName`, raw `args` with live transient stubs, a depth-limited `argsSummary`, `initiatorUserId` as the hex DO ID of the user DO, `initiatorModelId`, plus `resolve`/`reject`) and delivered once the agent finishes. Attempting a conflicting operation mid-run surfaces `AGENT_RUNNING_ERROR_MESSAGE`: `"Agent is running, wait for it to finish."`

## Outbound network posture: `global_fetch_strictly_public`

Gadget-adjacent outbound HTTP is constrained by a runtime flag rather than by hostname heuristics:

```jsonc title="packages/workshop-backend/wrangler.jsonc"
"compatibility_flags": [
  "allow_irrevocable_stub_storage",
  "enhanced_error_serialization",
  "global_fetch_strictly_public",
  "nodejs_compat"
]
```

`global_fetch_strictly_public` makes the global `fetch()` strictly fetch from the public internet in production, instead of the legacy behavior where same-zone requests go directly to origin, bypassing Cloudflare. Enforcement happens in workerd **after** DNS resolution: reserved ranges (loopback, RFC1918, link-local, cloud-metadata, and similar) are rejected post-lookup.

<Warning>
`wrangler dev` intentionally reconfigures its global outbound to permit fetching from any address so that localhost services stay reachable. The flag therefore only takes effect in production or when running `workerd` stand-alone — an accepted tradeoff for dev. When self-hosting with stand-alone `workerd`, blocking private-network addresses (and hostnames that resolve to them) is already the default.
</Warning>

### Why post-DNS filtering, not a hostname blocklist

`web-fetch.ts` makes the reasoning explicit: it does **not** inspect hostnames for "looks-internal" patterns, because that kind of blocklist is fundamentally unsound — a symbolic hostname can resolve to any IP at fetch time. Post-DNS-lookup filtering in the runtime is described as "the only correct place to enforce such restrictions."

`validateWebFetchUrl(input: string): URL` therefore checks only what is decidable from the URL string:

| Check | Failure message |
| --- | --- |
| Parses as a `URL` | `Invalid URL: <input>` |
| `protocol === "https:"` | `Only https:// URLs are allowed; got <scheme>//. Use the HTTPS version of this URL.` |
| No `username` / `password` | `URLs with embedded credentials are not allowed.` |

### `webFetch` shape and limits

The agent's built-in fetch capability is HTTP GET only against public HTTPS URLs. There is intentionally no support for POST/PUT/DELETE/PATCH and no credential forwarding.

<ParamField body="url" type="string" required>
Target URL. Must be `https:` with no embedded credentials.
</ParamField>

<ParamField body="raw" type="boolean">
If true, return the exact response bytes decoded as UTF-8 with no document conversion. If false or omitted, supported document formats are converted to Markdown via `env.WORKERS_AI.toMarkdown()`.
</ParamField>

<ParamField body="maxBytes" type="number">
Caller-requested cap on body length in characters. The server enforces its own hard cap on top.
</ParamField>

Result fields are `status`, `finalUrl`, `contentType`, `body`, and `truncated`.

| Server-side limit | Value |
| --- | --- |
| `HARD_MAX_BYTES` | `5 * 1024 * 1024` (5 MiB — always truncate beyond this) |
| `DEFAULT_MAX_BYTES` | `1 * 1024 * 1024` (1 MiB when the caller did not specify) |
| `FETCH_TIMEOUT_MS` | `30_000` |
| `USER_AGENT` | `GadgetsWebFetch/1.0` |

`readBodyCapped` fills the byte budget exactly, then cancels the remainder of the stream to free server-side resources and releases the reader lock.

### Conversion allow-list

`TO_MARKDOWN_MIME_TYPES` gates which responses are handed to `toMarkdown()`: `text/html`, `application/xhtml+xml`, `application/pdf`, `application/xml`, `text/xml`, `text/csv`, plus Office/OpenDocument types (`.docx`, `.xlsx`, `.xls`, `.xlsm`, `.xlsb`, `.ods`, `.odt`, `.numbers`). Plain-text, JSON, and other unknown content types pass through unconverted.

Image MIME types are excluded on purpose: image conversion uses paid Workers AI models (object detection plus Gemma-3 for image-to-text), and `webFetch` should not silently incur per-fetch cost. For the same reason HTML conversion passes `images: { convert: false, convertOGImage: false }`, giving the agent a Markdown skeleton with alt text and `src` URLs, and sets `html.hostname` to the page origin so relative links resolve.

Gateway routing is narrow: `buildGatewayOptions` returns options only when an `AiGatewayConfig` exists **and** it has a `workersAiGateway`, since `toMarkdown()` runs on the Workers AI binding and a cross-account platform gateway cannot be used by that binding. The tag sent through is `{ tool: "webFetch", automated: true }`.

<Info>
`WebFetchEnv` is deliberately narrow — `{ ai: Ai; gateway: AiGatewayConfig | null }` — so callers can pass a stub in tests without constructing a full `Cloudflare.Env`.
</Info>

## Code versions and gadget code flow

Code updates and their subscriptions are part of the `Overseer` RPC surface in `workshop-shared/src/api.ts`: `overseer.ts` imports `CodeUpdate` and `CodeSubscriber` alongside `GadgetMetadata`, `UiBundle`, `GadgetClient`, `GadgetBindingInfo`, and `WorkpieceSummary`/`WorkpiecesSubscriber`. Gadget file state itself is Yjs-backed (`import * as Y from "yjs"`), rooted per workpiece, and compaction checkpoints for the driving chat are stored in the `chatCompactions` collection via the typed-storage layer.

Blueprint-side artifacts are separate from live gadget code. `PublicApi` exposes them without authentication, on the grounds that knowing the ID is sufficient since a blueprint is "just data":

| Method | Behavior |
| --- | --- |
| `getBlueprint(id)` | Returns `BlueprintPublicInfo` or `null` if the blueprint does not exist |
| `downloadBlueprint(id)` | Returns a `.gadget` archive stream containing only `BlueprintMetadata` plus the current blueprint code snapshot — not the full KV record |

## Verification signals

<Steps>
<Step title="Confirm the loader binding exists">
`packages/workshop-backend/wrangler.jsonc` must contain a `worker_loaders` entry with `"binding": "LOADER"`. Without it, gadget code cannot be loaded as a Dynamic Worker.
</Step>
<Step title="Confirm the SSRF flag is present">
`global_fetch_strictly_public` must appear in `compatibility_flags`. Remember it is inert under `wrangler dev` — verify SSRF behavior in production or with stand-alone `workerd`, not against localhost.
</Step>
<Step title="Confirm DO migrations cover every class">
Each SQLite DO class must appear in exactly one migration tag (`v0`–`v2`). Classes are reached via `ctx.exports`, so a missing migration fails at runtime rather than at config-parse time.
</Step>
<Step title="Confirm binding names are validated">
Any new binding name path must run through `validateBindingName` from `@gadgets/workshop-shared/api`; names containing `$`, starting with a digit, or matching a reserved word are rejected.
</Step>
</Steps>

## Troubleshooting

<AccordionGroup>
<Accordion title="`Agent is running, wait for it to finish.`">
`AGENT_RUNNING_ERROR_MESSAGE` from `overseer.ts`. A conflicting operation was attempted while a `LiveChatContext` had an active agent turn. Callbacks that arrive in this window are queued as `QueuedAgentCallback` and delivered after the turn; other operations must wait or cancel via the context's `cancelController`.
</Accordion>
<Accordion title="Placeholder stub error mentioning persistent hook callbacks">
A stub produced by the harness's `[restore]` was invoked before being stored and read back. Such stubs are intended only to be passed to `bindHook()` and persisted; the real target appears after the storage round-trip.
</Accordion>
<Accordion title="`Only https:// URLs are allowed`">
`validateWebFetchUrl` rejects any non-`https:` scheme, and separately rejects URLs with embedded credentials. Internal-looking hostnames are *not* rejected here by design — those are blocked post-DNS by the runtime.
</Accordion>
<Accordion title="A localhost URL fetches successfully in dev">
Expected. `wrangler dev` reconfigures its global outbound to allow any address so local services stay reachable, so `global_fetch_strictly_public` has no effect there.
</Accordion>
<Accordion title="An image URL comes back unconverted">
Image MIME types are excluded from `TO_MARKDOWN_MIME_TYPES` on purpose, since image conversion invokes paid Workers AI models. Plain-text, JSON, and other types outside the allow-list also pass through unconverted.
</Accordion>
</AccordionGroup>

## Related pages

<CardGroup cols={2}>
<Card title="Routing and worker bindings" href="/routing-and-bindings">
Backend bindings including `LOADER`, `BROWSER`, `BLUEPRINTS`, and `BLUEPRINT_CONTENT`, plus DO migration tags and path-prefix routing.
</Card>
<Card title="RPC API reference" href="/rpc-api-reference">
`PublicApi`, `AuthenticatedApi`, and `Overseer`, plus `GadgetMetadata`, `UiBundle`, `CodeUpdate`, `validateBindingName`, and stub-disposal constraints.
</Card>
<Card title="Agent runtime and tools" href="/agent-runtime">
The Code Mode agent loop, `executeCode`, `setGadgetBinding`, `webFetch`, and how `prepareChatBindings` folds ambient gatekeepers into `env`.
</Card>
<Card title="Blueprints" href="/blueprints">
What a blueprint captures, its binding types, and `.gadget` export/import semantics.
</Card>
<Card title="Overview" href="/overview">
The router origin, Workers kernel, gadget sandboxes, and gatekeeper connectors, with the package layout.
</Card>
<Card title="Developer conventions and contributing" href="/conventions-and-contributing">
Kernel review standards for `workshop-backend` and `workshop-shared`, promise pipelining, and stub disposal.
</Card>
</CardGroup>
