Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
096ea5b
feat: per-run workerd sandbox that runs MCP Code Mode programs
MQ37 Jun 23, 2026
6abfe19
test: add apify binding smoke test (tests/binding-smoke.js + test.sh)
MQ37 Jun 24, 2026
b669c05
docs: tighten README copy and add compact apify binding reference
MQ37 Jun 24, 2026
f20b489
docs: move apify binding section above Learn more; trim intro copy
MQ37 Jun 24, 2026
63590fd
docs: drop run-token sentence from Permissions & safety
MQ37 Jun 24, 2026
8d0c7ff
docs: add docs/API.md detailed reference and link it from README
MQ37 Jun 24, 2026
4c3a430
docs: document exact output shapes and link each method to its Apify …
MQ37 Jun 24, 2026
db006c3
feat: add exitCode to run output
MQ37 Jul 7, 2026
6486cd0
fix: remove nodejs_compat to close sandbox egress and token leaks
MQ37 Jul 7, 2026
7a091e2
test: assert fetch allowlist covers apify.com + subdomains only
MQ37 Jul 7, 2026
2493f54
fix: block WebSocket and EventSource egress in guard.js
MQ37 Jul 7, 2026
5461aee
fix: close realFetch allowlist bypass via one-shot claim in guard.js
MQ37 Jul 13, 2026
c36f402
refactor: rename single-letter locals and de-duplicate the loopback port
MQ37 Jul 13, 2026
4607d26
fix: close redirect-following allowlist bypass, lock globalThis.fetch
MQ37 Jul 13, 2026
11e7c96
fix: catch usercode.js compile failures, scope run.abort, freeze bind…
MQ37 Jul 13, 2026
5bc3374
refactor!: rename actor.getDetails() to actor.get()
MQ37 Jul 13, 2026
d59cec3
refactor: migrate worker/*.js to TypeScript, add CI type-checking
MQ37 Jul 13, 2026
9020141
docs: drop TypeScript claim for the user's code input
MQ37 Jul 13, 2026
976d151
refactor!: rename actor.run/runAndGetItems, run.wait, dataset.getSchema
MQ37 Jul 13, 2026
8e18d6b
docs: self-contained actor.json description, dataset schema, README
MQ37 Jul 13, 2026
9d251d4
refactor: migrate tests/*.js probes to TypeScript, fix stale run.wait…
MQ37 Jul 14, 2026
2169a10
refactor!: apify.store top-level binding, kvs -> keyValueStore
MQ37 Jul 14, 2026
f23e4e3
feat: forward X-Apify-Request-Origin: MCP to sub-runs when this run i…
MQ37 Jul 14, 2026
2b84280
fix(ci): allow workerd postinstall build script under pnpm 11
MQ37 Jul 14, 2026
5fbbc53
docs: add Chain Actors recipe, dataset/keyValueStore usage examples
MQ37 Jul 14, 2026
0f61079
fix: use draft-07 dataset schema, not draft 2020-12
MQ37 Jul 16, 2026
854d536
fix: Docker builder stage doesn't need tests/ compiled
MQ37 Jul 16, 2026
c9d05b5
docs: warn against dangling-promise pattern and wrong API shape in co…
MQ37 Jul 16, 2026
6c52256
feat: opt into fullReadmeOnly, bypass the auto-generated summary
MQ37 Jul 16, 2026
77a3d8d
docs: lead schema-check guidance with fetch-actor-details, not just i…
MQ37 Jul 16, 2026
dae5b1d
docs: make dataset-ID reuse imperative, clarify non-terminal wait status
MQ37 Jul 17, 2026
776c581
feat!: dual-mode listItems/store, remove dataset.iterate()
MQ37 Jul 17, 2026
83eb3ae
docs: nudge Actor description/README with data-backed usage thresholds
MQ37 Jul 17, 2026
7346884
fix: trim actor.json description to fit under 300 chars
MQ37 Jul 17, 2026
a767a0e
docs: cut internal-telemetry section, dedupe repeated facts, tighten …
MQ37 Jul 17, 2026
2302300
docs: trim Code Mode/API-plumbing mentions from README
MQ37 Jul 17, 2026
2812c9c
chore: remove fullReadmeOnly actor.json flag
MQ37 Jul 17, 2026
8930b95
fix: gate realFetch claim on request handling, not import order
MQ37 Jul 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions .actor/actor.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
{
"actorSpecification": 1,
"name": "code-runtime",
"title": "Code Runtime",
"description": "Runs one JS script in a sandboxed Actor with an apify binding (Actors, datasets, KV stores). Worth it for bulk work: 50+ record filter/sort/aggregate, or 10+ item fan-out (e.g. many page visits). Skip for a single <10-item lookup \u2014 call the Actor directly instead. Read the README first.",
"version": "0.1",
"buildTag": "latest",
"usesStandbyMode": false,
"defaultRunOptions": {
"timeoutSecs": 900,
"memoryMbytes": 1024
},
"input": {
"title": "Code Runtime Input",
"description": "The program to run inside the sandbox.",
"type": "object",
"schemaVersion": 1,
"properties": {
"code": {
"title": "Code",
"type": "string",
"description": "JavaScript executed in the sandbox with `apify` and `console` globals; only console output is captured and pushed to the dataset as { stdout, stderr, exitCode, statusMessage } — a top-level `return` value is NOT captured. Before writing code that calls a specific Actor, check its real input field names first — via fetch-actor-details (outside this script, before you write it) or apify.actor.get({ actorId }) (inside it, for an Actor picked at runtime). Do not guess field names from memory; a wrong one throws a fast 400, but costs a wasted round trip. Print a small JSON summary of the result — never dump full datasets. Write top-level `await` statements directly in the script; do NOT wrap your logic in an async function you call without awaiting (e.g. `async function main(){...}; main()`) — the script returns as soon as the top-level body finishes, silently discarding anything still pending, with no error. Every apify.* method takes ONE options object keyed by id, e.g. apify.actor.call({ actorId, input }), apify.dataset.listItems({ datasetId, limit }) — this is NOT the public apify-client SDK's curried apify.actor(id).call(input) shape. If a prior attempt already logged a nested run's defaultDatasetId/defaultKeyValueStoreId (visible in your own earlier turns), reuse it — do NOT re-run the same Actor call with identical input, that wastes compute on a call that already succeeded. apify.actor.call/run.waitForFinish may return non-terminal (READY/RUNNING) once the 60s wait cap elapses — that is NOT a failure, poll again instead of throwing.",
"editor": "javascript"
}
},
"required": ["code"]
},
"output": {
"actorOutputSchemaVersion": 1,
"title": "Code Runtime Output",
"description": "One dataset item { stdout, stderr, exitCode, statusMessage } (exitCode: 0=returned, 1=threw); see the exitCode field description for the timeout/OOM case.",
"type": "object",
"properties": {
"output": {
"type": "string",
"title": "Execution output",
"template": "{{links.apiDefaultDatasetUrl}}/items"
}
}
},
"storages": {
"dataset": {
"actorSpecification": 1,
"description": "Present only if the script ran to completion (returned or threw). A run-level timeout or OOM kill produces zero items for that run — the calling Actor-run's own status (SUCCEEDED vs FAILED/TIMED-OUT) is the signal for that case, not item presence.",
"fields": {
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"stdout": {
"type": "string",
"title": "stdout",
"description": "Captured console.log/console.info output."
},
"stderr": {
"type": "string",
"title": "stderr",
"description": "Captured console.error/console.warn output, plus the thrown error (or compile failure) message when exitCode is 1."
},
"exitCode": {
"type": "integer",
"enum": [0, 1],
"title": "Exit code",
"description": "0 = script returned normally, 1 = script threw (or failed to compile)."
},
"statusMessage": {
"type": "string",
"title": "Status message",
"description": "Prose form of the same signal: 'Script completed' / 'Script threw: ...' / 'Failed to compile: ...'."
}
},
"required": ["stdout", "stderr", "exitCode", "statusMessage"]
}
}
},
"dockerfile": "../Dockerfile"
}
4 changes: 4 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules
.git
*.log
.DS_Store
19 changes: 19 additions & 0 deletions .github/workflows/typecheck.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
name: Typecheck

on:
push:
branches: [master]
pull_request:

jobs:
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run typecheck
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
node_modules/
# Generated at container startup by entrypoint.sh (never checked in):
worker/usercode.js
# Compiled from worker/*.ts and tests/*.ts by `pnpm build` (tsconfig.json emits next to source):
worker/runner.js
worker/guard.js
tests/*.js
*.log
.DS_Store
43 changes: 43 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Two-stage build: drop the Node runtime entirely. workerd is a standalone
# glibc binary; only libc + libm are needed at runtime (verified via `ldd`).
#
# Stage 1: pull the workerd binary + compile worker/*.ts -> worker/*.js, via a Node
# base. Node already runs here to resolve workerd's binary path, so compiling
# TypeScript is one more RUN line, not a new toolchain. pnpm keeps workerd in the
# virtual store (not hoisted), so resolve the path through `require('workerd')`.
FROM node:24-bookworm-slim AS builder
WORKDIR /build
COPY package.json pnpm-lock.yaml tsconfig.json ./
COPY worker/ ./worker/
# --ignore-scripts skips workerd's postinstall (a binary-download fallback we
# don't need — the binary ships in the @cloudflare/workerd-linux-64 optional dep)
# and avoids pnpm's hard error on unapproved dependency build scripts. Full
# (non --prod) install: typescript is a devDependency, needed to compile below.
# Compile with tsc directly, not `pnpm run build`: that script also sed's
# tests/*.js (dev-only probe fixtures for test.sh, submitted as Actor input at
# run time — never part of the image), which isn't copied into this build
# context and doesn't need to be; tsconfig's tests/*.ts include glob simply
# matches nothing here.
RUN corepack enable \
&& pnpm install --frozen-lockfile --ignore-scripts \
&& pnpm exec tsc -p tsconfig.json \
&& BIN="$(node -e "process.stdout.write(require('workerd').default)")" \
&& cp "$BIN" /workerd \
&& chmod +x /workerd

# Stage 2: minimal runtime — debian + ca-certificates + the workerd binary + the
# compiled JS. No Node, no TypeScript, no npm packages in this image.
FROM debian:bookworm-slim

# curl: loopback HTTP client + Actor-input fetch; jq: extract `code` from the input JSON.
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl jq \
&& rm -rf /var/lib/apt/lists/*

COPY --from=builder /workerd /usr/local/bin/workerd

WORKDIR /app
COPY worker/entrypoint.sh worker/config.capnp ./worker/
COPY --from=builder /build/worker/runner.js /build/worker/guard.js ./worker/

ENTRYPOINT ["sh", "/app/worker/entrypoint.sh"]
212 changes: 212 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1 +1,213 @@
# Code Runtime (experimental)

> ⚠️ **Experimental infrastructure Actor.** It powers **Code Mode** on
> [mcp.apify.com](https://mcp.apify.com) and is normally invoked by the Apify
> MCP Server, not run by hand. Its behaviour and API may change without notice.

## What it does

Executes one JS script that an AI agent submits through the Apify MCP
Server, then returns whatever the script printed.

Lets an agent do many Apify operations in **one call** — search the Store,
run an Actor, read its dataset, filter and aggregate — instead of sending
every intermediate result back through the model. This Actor is the sandbox
that runs that script.

**Worth it only for bulk work** (measured, A/B eval vs. calling Actor tools
directly):

| Workload | Verdict |
|---|---|
| Filter/sort/aggregate 50+ dataset records | Modest win — ~20-35% less time, ~20% fewer tokens |
| Fan out over 10+ sub-resources with a sizeable payload each (visit many pages, chain Actors) | Decisive win — ~60% less time, ~75% fewer tokens |
| Under 10 items, no fan-out | **Don't use this Actor** — ~20K-token sandbox overhead isn't paid back |

## Calling this Actor

Self-contained — no special MCP-server opt-in required. Any MCP client
already has `search-actors`, `fetch-actor-details`, and `call-actor` as
default tools:

```
call-actor({ actor: "apify/code-runtime", input: { code: "..." } })
```

Default `timeoutSecs: 900`, `memoryMbytes: 1024` (`.actor/actor.json`) —
override per call for scripts chaining several long Actor runs (MCP
`call-actor`'s `callOptions.timeout`/`callOptions.memory`, or the API's
`timeout`/`memory`).

## How it works

- **One script per run.** Reads `code`, runs it once, writes the result, exits.
- Runs inside a sandboxed [`workerd`](https://github.com/cloudflare/workerd)
V8 isolate — see [Permissions & safety](#permissions--safety) for what's allowed.
- A global **`apify`** object exposes a small, typed subset of the Apify API
— run Actors, read/write datasets and key-value stores — using the
current run's token.
- `console.log`/`console.info` → **stdout**; `console.error`/`console.warn`
→ **stderr**, captured separately.
- Call `apify.actor.get({ actorId })` before running an Actor you haven't
checked — don't guess its input schema.
- Log a nested run's `run.id`/`defaultDatasetId`/`defaultKeyValueStoreId`
before processing its output — nothing persists between this Actor's own
runs, but the Actors it started keep theirs.
- Already have a dataset/store ID from an earlier turn? Reuse it — don't
re-run an identical call, it wastes cost.
- Print a small JSON summary, never a full dataset — only `console.log`/
`console.info` output comes back; a top-level `return` is **not** captured.

## Input

```json
{
"code": "const { items } = await apify.actor.callAndGetItems({ actorId: 'apify/rag-web-browser', input: { query: 'apify' }, limit: 3 });\nconsole.log(items.map((i) => i.metadata?.title).join('\\n'));"
}
```

| Field | Type | Description |
|---|---|---|
| `code` | string | The JavaScript script to run (JS only, not transpiled). It receives the `apify` binding and `console`. |

## Output

A single **dataset item**:

```json
{ "stdout": "Apify: Full-stack web scraping ...\n...", "stderr": "", "exitCode": 0, "statusMessage": "Script completed" }
```

| Outcome | `exitCode` | `statusMessage` |
|---|---|---|
| Script returned | `0` | `Script completed` |
| Script threw | `1` | `Script threw: ...` |
| Failed to compile (syntax error) | `1` | `Failed to compile: ...` |
| Run-level timeout / OOM kill | — | item may not exist for this run at all |

Check `exitCode`/`statusMessage`, not `stderr` content, to detect a failed
script — `stderr` also carries `console.error`/`console.warn` output, so its
presence alone isn't failure. A timeout/OOM kill is signaled by the Actor
run's own status (`SUCCEEDED` vs `FAILED`/`ABORTED`/`TIMED-OUT`), not by this
item's absence.

## Permissions & safety

- Sandbox has **no filesystem**; outbound `fetch` (redirects re-validated
per hop) is limited to the Apify API (`*.apify.com`).
- **No imports** — runs without workerd's `nodejs_compat`, so no Node
built-ins (`node:net`, `node:fs`, …) or npm packages. This also removes
`node:net` (a raw-socket path that would bypass the `fetch` allowlist) and
keeps the run token out of `process.env` (undefined here).
- Each run is an isolated, single-use container — nothing persists between runs.
- This closes **direct fetch-based exfil** — it does not close every path to
move data out (e.g. `actor.start({ input })` on an Actor with its own
internet access, or writing to a dataset/key-value store).

## Recipes

### Chain Actors (one run's output feeds the next)

```js
const { items: results } = await apify.actor.callAndGetItems({
actorId: 'apify/google-search-scraper', input: { queries: 'apify' }, limit: 10,
});
const startUrls = results.flatMap((r) => r.organicResults ?? []).map((r) => ({ url: r.url }));
const { items: pages } = await apify.actor.callAndGetItems({
actorId: 'apify/website-content-crawler', input: { startUrls },
});
console.log(JSON.stringify(pages.slice(0, 3).map((p) => p.url)));
```

### Bounded parallel fan-out

This Actor's clearest win: run several Actors (or the same Actor over
several inputs) concurrently, then reduce before returning. Chunk it (5–10
at a time) — an unbounded `Promise.all` can hit your account's
concurrent-run or memory limits.

```js
const inputs = [{ query: 'a' }, { query: 'b' }, { query: 'c' } /* ... */];
const CHUNK = 5;
const results = [];
for (let i = 0; i < inputs.length; i += CHUNK) {
const batch = inputs.slice(i, i + CHUNK);
const batchResults = await Promise.all(
batch.map((input) => apify.actor.callAndGetItems({ actorId: 'apify/rag-web-browser', input, limit: 5 })),
);
results.push(...batchResults.flatMap((r) => r.items));
}
console.log(JSON.stringify(results.slice(0, 5))); // small summary, not the full dump
```

### Read an entire dataset without managing offsets

`dataset.listItems`/`store` work two ways — `await` for one page, `for
await` to auto-paginate every item (see [the apify binding](#the-apify-binding)):

```js
// One page — e.g. a quick peek
const { items, count } = await apify.dataset.listItems({ datasetId, limit: 10 });

// Every item, however many pages that takes
let matches = 0;
for await (const item of apify.dataset.listItems({ datasetId })) {
if (item.rating >= 4.5) matches++;
}
console.log(`${matches} matching items`);
```

### Runs longer than 60s: start, then poll

`actor.call`'s wait is capped at 60s per request (a REST API limit, not this
Actor's). For a longer-running Actor, start it and poll:

```js
let run = await apify.actor.start({ actorId, input });
const TERMINAL = ['SUCCEEDED', 'FAILED', 'ABORTED', 'TIMED-OUT'];
while (!TERMINAL.includes(run.status)) {
run = await apify.run.waitForFinish({ runId: run.id, waitForFinishSecs: 60 });
}
```

## The `apify` binding

Every method takes one options object and returns parsed JSON — except
`store` and `dataset.listItems`, which return a value that's both a
`Promise` (one page) and an `AsyncIterable` (every match/item,
auto-paginated). Full API docs:
[API.md](https://github.com/apify/actor-code-runtime/blob/master/docs/API.md).
(`?` = optional, `= x` = default)

```js
// Store — GET /v2/store, a top-level Apify API resource (not an Actor method)
apify.store({ search, limit?, offset?, category? }) // → { items, count, offset, limit }; dual Promise/AsyncIterable, see above

// Actors
apify.actor.get({ actorId }) // → actor
apify.actor.start({ actorId, input?, memoryMbytes?, timeoutSecs?, maxTotalChargeUsd?, maxItems? }) // → run
apify.actor.call({ actorId, ...startOpts, waitForFinishSecs = 60 }) // → run (may be non-terminal READY/RUNNING past the 60s cap — not an error, see Recipes)
apify.actor.callAndGetItems({ actorId, input?, fields?, limit?, ...runOpts }) // → { run, items }

// Runs
apify.run.get({ runId }) // → run
apify.run.waitForFinish({ runId, waitForFinishSecs = 60 }) // → run (same non-terminal caveat)
apify.run.abort({ runId }) // → run
apify.run.getLog({ runId, limit? }) // → string

// Datasets
apify.dataset.create({ name? }) // → dataset
apify.dataset.pushItems({ datasetId, items }) // → void
apify.dataset.listItems({ datasetId, fields?, omit?, limit?, offset?, clean?, desc? }) // → { items, count, offset, limit, desc }; dual Promise/AsyncIterable, see above
apify.dataset.inferFields({ datasetId, sample = 5 }) // → { itemCount, fields[] }

// Key-value stores
apify.keyValueStore.create({ name? }) // → store
apify.keyValueStore.set({ storeId, key, value, contentType? }) // → void
apify.keyValueStore.get({ storeId, key }) // → value | null
apify.keyValueStore.list({ storeId, limit?, exclusiveStartKey? }) // → { items }
```

## Learn more

- Apify MCP Server: <https://mcp.apify.com>
Loading
Loading