feat: implement the code mode runtime Actor#1
Conversation
aed71df to
3286abf
Compare
3286abf to
096ea5b
Compare
Write { stdout, stderr, exitCode } instead of { stdout, stderr }. exitCode is
the user script's effective status: 0 when it returns normally, 1 when it
throws. The Actor run itself still SUCCEEDS on a throw, so callers detect a
failed script via exitCode rather than heuristics on stderr (console.error /
console.warn are legitimate log channels). Update README and API docs.
The workerd nodejs_compat flag exposed Node built-ins to user code, which broke the sandbox's egress boundary two ways (ai-team#216): - node:net gave a raw-socket egress path that bypassed guard.js's *.apify.com fetch allowlist — arbitrary public hosts were reachable (finding A). - process.env exposed the run's APIFY_TOKEN to user code (finding B). It also made the docs' 'no imports' claim false (finding C). runner.js and guard.js use only web-standard APIs (fetch/URL/Response/ Uint8Array), so dropping the flag needs no code changes. Without it, node:* imports fail and process/require are undefined, while fetch and the apify binding keep working. Add tests/sandbox-isolation.js (run via test.sh, same deploy+call pattern as binding-smoke.js) asserting node builtins are blocked, process/require are undefined, fetch works, guard.js still blocks non-apify hosts, and the apify binding still works. Verified on the deployed Actor: isolation 9/9, binding smoke 18/18. Update docs to state no imports are available.
Expand the sandbox isolation probe: positively verify apify.com and an *.apify.com subdomain are allowed, and that guard.js blocks unrelated hosts, subdomain/userinfo look-alikes (evilapify.com, apify.com.evil.com, apify.com@evil.com), and the cloud metadata IP. Classify by the guard's 'Blocked fetch' rejection, not by request success. Verified on the deployed Actor: 15/15.
Removing nodejs_compat closed the node:net egress path, but WebSocket and EventSource are web-standard globals present without that flag and connect directly, not through the fetch guard. A script could open a wss:// or SSE channel to any public host and exfiltrate data around the *.apify.com allowlist (apify/ai-team#216 finding A, second primitive). runner.js and the apify binding use only fetch, so neutralize both globals (non-configurable throwing stubs). This closes the JS egress surface: fetch is allowlisted, WebSocket/EventSource are removed, and raw sockets need module imports that are already blocked. Extend the isolation probe with wss:// and SSE egress cases. Verified on the deployed Actor: isolation 17/17, binding 18/18.
|
I wanted to add myself as an a reviewer, but I can't, so here we go. First: nice work. The big calls are right, and the ones that are easy to get wrong you got right. The rest is written mostly by Claude with my edits. Things I want to agree on1. Write the Actor's own code in TypeScript. 2. 3. Give the runner a real status message.
4. The binding docs have to live in the Actor. Stop renaming the Apify vocabulary and this is not bike-shedding, it is important for LLMsThe binding renames a lot of Rule I'd propose: match the API / Note: I like
The run-option paramsHere's the full picture across every layer, because it's the confusing bit:
FindingsSecurity — I'd fix these before it leaves experimental
Robustness
Contract / maintainability
Minor
Confirmed good — leave these alone |
guard.js exported realFetch as a standing module binding. Since ES modules
are singleton-cached, the sandboxed user script could recover it at runtime
via `(await import('./guard.js')).realFetch(...)`, fully bypassing the
*.apify.com fetch allowlist (and reaching Apify's private network, since the
outbound service allows public/private/local). Reopened exactly the egress
class apify/ai-team#216 was meant to close.
guard.js now exposes claimRealFetch(), a one-shot accessor that hands out the
real fetch once and nulls its internal reference. runner.js claims it during
its own module load, strictly before usercode.js can run. A later
import('./guard.js') from inside the script reaches the same cached module
instance, but the value is already gone.
Verified against real workerd, before/after, same PoC:
- before: guard.js exports ["realFetch"] -> BYPASS SUCCEEDED, exfil request
logged on a disallowed listener.
- after: guard.js exports ["claimRealFetch"] -> BYPASS FAILED (null), zero
hits on the disallowed listener.
runner.js: r -> response, d -> page, [k, v] -> [key, value], ct -> contentType / resolvedContentType. Matches code-quality and apify-coding-standards naming rules (no single-letter/abbreviated locals). config.capnp + entrypoint.sh both hardcoded the loopback port 8787 independently. config.capnp now takes a __PORT__ placeholder that entrypoint.sh substitutes from its own $PORT before starting workerd \u2014 one source of truth.
guard.js validated only the initial URL; fetch defaults to redirect:'follow', so an allowlisted *.apify.com host issuing a 3xx to any other host was followed out silently. Verified live against real workerd: an allowed host redirecting to a disallowed one reached it (before) / was blocked (after). Now fetches with redirect:'manual' and re-validates each Location against the allowlist before following, one hop at a time (capped at 5), with WHATWG-spec method/body downgrade rules (303 always -> GET; 301/302 -> GET only if the original method was POST; 307/308 preserve method + body). Also lock globalThis.fetch via Object.defineProperty(writable:false, configurable:false), matching the existing WebSocket/EventSource treatment (a plain assignment could be reassigned/deleted by the sandboxed script). Verified live: reassigning globalThis.fetch from a script now throws TypeError instead of succeeding.
…ings
usercode.js wraps the user's code inside 'export async function run(...) {
... }' with nothing else at module scope, so a syntax error in it fails
module evaluation before any request reaches runner.js's try/catch \u2014 workerd
exits immediately (verified: raw workerd binary, exit code 1, no /health
response, ever). The reviewer's proposed fix (dynamic import inside the
try/catch) does NOT help: verified live that workerd eagerly evaluates every
module declared in config.capnp regardless of static vs dynamic import, so
both crash identically. The actual fix has to live in entrypoint.sh, which
is what this commit does:
- entrypoint.sh now captures workerd's stderr and, if workerd exits before
becoming ready, checks whether the crash names usercode.js. If so (this is
structurally exact, not a heuristic, given usercode.js's shape above) it
pushes a { stdout: '', stderr, exitCode: 1, statusMessage: 'Failed to
compile: ...' } item directly and exits 0, so the run SUCCEEDS with
diagnostics instead of failing as an opaque infra error. Any other crash
(our own runner.js/guard.js, config issue) still hard-fails the run.
Verified live for both the positive (usercode.js) and negative
(unrelated file) cases.
- runner.js adds the same statusMessage field ('Script completed' /
'Script threw: ...') to the normal exitCode 0/1 path, so callers get a
prose signal without branching on exitCode.
- run.abort({ runId }) is now scoped to run IDs this script itself started.
actor.run()/actor.start() share one createRun() helper that records each
created run's ID in a Set; abort() throws on an unrecognized runId instead
of silently no-op'ing (a script shouldn't think an abort succeeded when it
didn't). Previously any account-wide runId could be aborted. Verified live.
- console and the apify binding's namespaces are now Object.freeze'd so a
script can't reassign e.g. console.log to corrupt its own output capture.
Verified live: reassignment now throws TypeError.
- entrypoint.sh also validates the default dataset ID up front (needed by
the new compile-failure push path), matching the existing token/KV-store
check.
Council-reviewed rename (both a data-structure and an interface-design lens independently converged on this one): actor.get matches run.get's existing naming convention in this same binding, which getDetails did not. Breaking change, acceptable pre-release. docs/API.md and README.md's binding summary updated to match; the fuller docs/description self-containment pass (a separate, later PR) will carry this through the rest of the copy.
worker/guard.js and worker/runner.js -> guard.ts / runner.ts, with real types
for the apify binding's full public surface (every method's options object
named and typed, not `any`), the Env/Run/ApifyRecord shapes this code reads,
and the redirect/fetch-guard helpers. Compiles clean under strict:true.
API response shapes stay honestly `any` at the api{Json,Data} boundary --
the Apify API's JSON envelope isn't something this repo has a verified schema
for, and asserting a precise shape we haven't checked would be worse than not
typing it. Everything this code actually authors (every destructured options
object, every local helper's params/return) is fully typed.
Dockerfile's builder stage already runs Node (to resolve the workerd binary
path via require('workerd')) -- compiling TypeScript there is one more RUN
line, not a new toolchain. The runtime stage still copies only the compiled
JS + entrypoint.sh + config.capnp; no Node, no npm packages, no .ts sources
ship in the final image. worker/runner.js and worker/guard.js are now build
artifacts (pnpm build / pnpm typecheck), gitignored, generated from the .ts
source at Docker build time -- matching how the workerd binary itself is
already obtained via the same Node stage.
Added .github/workflows/typecheck.yml (pnpm typecheck on push/PR) -- this is
the actual CI gap the reviewer flagged; it needs no Apify credentials, unlike
test.sh's live apify push + call, so it's safe to run automatically.
Verified zero behavioral regression: reran the full live-workerd suite
(valid script, usercode.js compile-failure diagnostic, runtime throw,
run.abort scoping, console/apify-binding freeze, redirect-following allowlist
bypass) against the freshly compiled output -- all six identical to the
pre-migration JS.
The code input is fetched at container runtime and wrapped verbatim into usercode.js (entrypoint.sh); nothing transpiles it and workerd doesn't strip types, so a TypeScript type annotation in user code is a SyntaxError at load today, not a supported input. actor.json's description/input schema and README's intro + input table claimed TypeScript/JavaScript; corrected to JavaScript only, with the SyntaxError-at-load reason stated so it's not mistaken for an oversight. Scoped to the user-facing code input contract only -- the separate question of what language the Actor's own source is written in was resolved in the previous commit (TypeScript, compiled at build time).
Council-reviewed renames (both a data-structure and an interface-design lens
independently kept these three, out of a larger proposed table):
- actor.run() -> actor.call(): 'run' was doing double duty as a verb (start+wait)
and as the top-level run-management namespace. call avoids the collision and
matches the CLI (apify call) / MCP (call-actor) vocabulary.
- actor.runAndGetItems() -> actor.callAndGetItems(): follows from the above.
- run.wait() -> run.waitForFinish(): passes straight through to the REST
waitForFinish query param; matches the platform vocabulary. The waitForFinishSecs
*parameter* name is intentionally NOT touched -- pairing this method rename with
dropping the unit suffix would produce waitForFinish({ waitForFinish: 30 }),
read as a boolean, not a duration.
- dataset.getSchema() -> dataset.inferFields(): 'schema' already means two other
things in this Actor's own ecosystem (input schema, and this Actor's own
*declared* dataset schema in actor.json) -- inferFields names what the method
actually does (infer field types from a sample) without colliding.
Breaking change, acceptable pre-release. Verified live: apify.actor.call,
apify.actor.callAndGetItems, apify.run.waitForFinish, apify.dataset.inferFields
all exercised end-to-end against a mocked Apify API via real workerd.
docs/API.md and README.md still describe the old names -- updated in the
next commit alongside the rest of the self-containment docs pass.
Final self-containment pass (previously planned as a separate stacked PR, landing directly on this branch per instruction). All content was council- reviewed earlier in the design phase; this lands it against the actual current code (post rename + statusMessage + TS migration). .actor/actor.json: - description (249/300 chars): what it does, the data-heavy/fan-out framing, sandbox limits moved out to README (kept here: run mechanics + billing). - input.code.description (375/500 chars): the correctness-critical facts -- only console output round-trips (a top-level return is NOT captured), call apify.actor.get() before running an Actor, print a small summary. - output.description: now includes exitCode + statusMessage. - defaultRunOptions: timeoutSecs 900, memoryMbytes 1024 -- this Actor had no default before; overridable per call. - storages.dataset: declared fields (stdout/stderr/exitCode/statusMessage, exitCode kept as enum:[0,1] -- not widened to nullable, see prior design notes) plus a dataset-level description stating the run-level-kill cardinality caveat (zero items is a distinct case from any field's value). No `views` block -- a single always-one-item dataset doesn't need one. README.md: - "Calling this Actor" replaces the stale `?tools=run-code,get-code-docs` section -- that mechanism doesn't exist; call-actor/search-actors/ fetch-actor-details are already default MCP tools, no opt-in needed. - Data-heavy/fan-out framing + the free-text-extraction anti-pattern (kept here, cut from the character-capped actor.json description). - Workflow tips (get-before-running, log storage IDs before processing, print-small-summary, return values aren't captured) folded into "How it works" rather than a new section duplicating the code-input description. - "Limits & failure modes": defaultRunOptions + override, and the exitCode/statusMessage-vs-run-status distinction for resource kills. - "Recipes": bounded parallel fan-out (this Actor's actual measured strength) and the >60s start+poll pattern. - "Limitations": sub-Actor runs aren't MCP-attributed -- known, unresolved, tracked separately, not fixed by this PR. - Egress-safety wording softened to "no direct fetch-based exfil", not "contained" -- actor.start()/dataset writes are still exfil paths outside this guard's scope. - Binding summary + Input example updated to the renamed methods (call/callAndGetItems/waitForFinish/inferFields/get). docs/API.md: renamed section headers/cross-references to match (actor.call, actor.callAndGetItems, run.waitForFinish, dataset.inferFields). package.json: description no longer claims "Worker Loader API" (this Actor is one static worker, not per-request isolate loading) -- stale since before this branch, corrected here alongside the rest of the accuracy pass.
… doc link
- tests/binding-smoke.ts, tests/sandbox-isolation.ts: typed against the same
ApifyBinding surface runner.ts exposes at runtime (exported type-only from
runner.ts), and updated to the renamed methods (get, call, callAndGetItems,
waitForFinish, inferFields) that a prior commit renamed but these probes
still called under their old names.
- tests/globals.d.ts: ambient apify/process/require declarations via
`declare global` for these standalone-compiled probe files (apify/console
are real function parameters at runtime once entrypoint.sh splices the
compiled JS into the code input, not globals).
- tsconfig.json: tests/*.ts now type-checked alongside worker/*.ts.
- package.json build: strips the `export {};` marker tsc appends to each
probe (needed so tsc treats each file as its own module — otherwise their
top-level consts collide across files, and top-level await needs a module).
Left in, that line would be a syntax error once spliced into the wrapping
`async function run(apify, console) { ... }`. Verified with node --check
against the actual wrapped shape.
- test.sh: runs `pnpm build` before pushing/calling so probes compile first.
- docs/API.md: fix a stale `run.wait` anchor missed by an earlier rename
commit (actual method is `run.waitForFinish`).
Revisits the two renames the council flagged as bikeshedding and deferred
to v2 (now resolved per your call: breaking changes are fine, this is a POC).
- api.apify.com/v2/openapi.json tags GET /v2/store as its own top-level
'Store' resource (operationId store_get), sibling to 'Actors', not a
sub-resource of it. Moved actor.search() out of the actor.* namespace to a
bare apify.store({ search, limit?, category? }) binding to match — the
Store resource has exactly one operation, so a namespace object would be
one method wrapping nothing.
- Renamed the search param query -> search to match the API's own field
name (GET /v2/store?search=...), so the options object no longer invents
a name the wire format doesn't use.
- Renamed kvs -> keyValueStore (namespace + all Kvs*Options interfaces) to
match the API's own key-value-stores resource name instead of an
abbreviation found nowhere in the Apify API itself.
- README, docs/API.md, tests/*.ts updated to match. docs/API.md gets its own
## apify.store section ahead of ## apify.actor.
…s MCP-started Closes (partially) the MCP-attribution gap flagged in README's Limitations. Verified end-to-end against apify-core / apify-worker source (not just docs): - apify-mcp-server sends X-Apify-Request-Origin: MCP on every API call (src/apify_client.ts). - apify-core's requestOriginParserMiddleware validates it against META_ORIGINS (includes MCP) and sets req.origin (src/api/src/middleware/request_origin_parser.ts). - actor_jobs.ts's createMeta(req.origin || defaultOrigin, ...) -- defaultOrigin is META_ORIGINS.ACTOR for actor-run-token callers -- stores it as the new run's meta.origin (src/api/src/lib/actor_jobs.ts:374-378). - apify-worker injects it back into the container as APIFY_META_ORIGIN (act2_run_job.ts:2212, APIFY_ENV_VARS.META_ORIGIN = "APIFY_META_ORIGIN", matches apify-docs' environment_variables.md). So this Actor's own container already receives APIFY_META_ORIGIN=MCP when apify-mcp-server started it -- no mcp-server change, no new Actor input field. Rejected a hidden `isMCPRun` input field: call-actor's tool schema is built directly from actor.json, so a hidden field is either LLM-visible-and-spoofable or never set by anyone. APIFY_META_ORIGIN is platform-injected and unspoofable from inside the sandbox. - worker/config.capnp: bind PARENT_ORIGIN from APIFY_META_ORIGIN (same pattern as the existing APIFY_TOKEN binding). - worker/runner.ts: makeApifyBinding() takes parentOrigin; sends X-Apify-Request-Origin: MCP on all its own API calls only when parentOrigin === 'MCP'. Also sets a plain User-Agent (previously unset). - README: rewrote Limitations to document the (already-existing, no-code-needed) meta.actorRunId parent-run link apify-core sets on every sub-run automatically, and what this change adds on top of it. Verified live with the real workerd binary + a mock Apify API asserting the header: absent when APIFY_META_ORIGIN is unset, absent when it's 'ACTOR', present as 'MCP' only when it's 'MCP'.
pnpm 11 blocks dependency install scripts by default (ERR_PNPM_IGNORED_BUILDS)
unless explicitly allowed. Adds pnpm-workspace.yaml with allowBuilds: { workerd:
true } -- workerd's postinstall fetches its platform-specific binary, which is
what CI's pnpm typecheck (and every other pnpm command) needs present.
Closes 2 of the 4 doc-parity gaps found vs. apify-mcp-server#1044's original guide content (the other two -- discover-and-inspect snippet, Promise.allSettled fix for the fan-out recipe -- postponed): - README: Chain Actors recipe (one run's output feeds the next), using the renamed callAndGetItems. - docs/API.md: chained create -> pushItems -> listItems example for apify.dataset (previously signature tables only); chained create -> set -> get example for apify.keyValueStore (previously zero code examples in that section).
Actor build failed with: Dataset schema compilation failed with: no schema with key or ref "https://json-schema.org/draft/2020-12/schema" apify-core compiles storages.dataset.fields with a plain `new Ajv(...)` (src/packages/storages/src/dataset_validation.ts) - no Ajv2020 variant, no addMetaSchema for 2020-12, so that $schema value has no meta-schema to resolve against. Switch to draft-07, which this Ajv instance supports natively; the schema itself only uses draft-07-compatible keywords (type/properties/required/enum), so no semantic change. Also nudge the agent to read the README before using the Actor, added to the top-level description (277/300 chars).
Build failed:
sed: can't read tests/*.js: No such file or directory
The builder stage only COPYs worker/ (tests/ is dev-only probe
fixtures for test.sh, submitted as Actor input at run time - never
part of the image, correctly never copied in). `pnpm run build`
compiles both worker/*.ts and tests/*.ts per tsconfig, then sed's the
tsc-appended `export {};` marker out of tests/*.js - that sed fails
outright when the directory doesn't exist.
Call tsc directly instead of the shared pnpm script: tsconfig's
tests/*.ts include glob simply matches nothing when the directory is
absent (no error), producing exactly worker/runner.js + worker/guard.js
- the only two files stage 2 copies. Local dev / test.sh, where tests/
does exist, are unaffected.
Verified: reproduced the exact original failure in an isolated copy of
the builder context (no tests/), confirmed tsc alone succeeds and
produces both worker/*.js files, then ran the full multi-stage
Dockerfile build to completion with podman.
…de field
Agent transcripts show scripts wrapping logic in an unawaited
'async function main(){...}; main().catch(...)' pattern truncate
silently after the top-level body returns — no error, no partial
result, exitCode 0. Also confirmed agents defaulting to the public
apify-client SDK's curried .actor(id).call() shape instead of this
binding's flat { actorId, input } options-object shape.
Both warnings added to the code field's own description since it's
always in context (unlike the README, which agents often skip
fetching before first use).
This Actor's README documents an exact API contract (apify.* method names/shapes) -- the platform's auto-generated readmeSummary omits that section entirely, confirmed via a live eval trace where the agent fetched the README, got the summary, and guessed the wrong dataset method as a result. Requires the matching apify-mcp-server change (resolveReadmeContent honoring this flag). Unverified: whether this unrecognized actor.json key survives the platform's build validation into the API response -- needs a live check after this Actor is rebuilt.
…n-sandbox check
Live eval trace: agent wrote apify.actor.callAndGetItems({ actorId:
'apify/rag-web-browser', input: { url, maxPages } }) from memory --
wrong field names (real one is 'query') -- despite this field's
existing 'call apify.actor.get() first' guidance. All 20 calls failed
fast with a clear 400, so no time was wasted discovering it, but it
still cost a full wasted round trip before the agent self-corrected.
The in-sandbox-only phrasing was easy to skip since it costs an extra
nested Actor call inside the script. Reworded to lead with the outer
fetch-actor-details tool -- the same tool the agent already uses
correctly for every other Actor it discovers via search-actors, just
not yet for ones it only decides to call while writing code -- and
keep apify.actor.get() as the fallback for Actors picked at runtime.
Council review (Torvalds/Hotz/Pike/Hoare) + live eval traces: 1 of 3 recovery
attempts wasted a full re-run of an already-succeeded nested Actor call
instead of reusing its logged defaultDatasetId, because the guidance was
advisory ("can read those existing storages") not a rule. Reworded to an
imperative 'reuse it, do not re-run' in the code field description, README,
and docs/API.md.
Separately: actor.call/run.waitForFinish's 60s wait cap was already
documented, but a trace showed an agent still treating a returned
READY/RUNNING status as a hard failure right after a single wait -- the
cap and its corollary (non-terminal isn't an error) lived in two different
places. Added an inline note directly on the binding-table/method-doc lines
themselves, not just the separate polling recipe.
Grounded in a fresh clone of apify-client-js (the real Apify TS SDK) and a
council review (Torvalds/Hotz/Pike/Hoare) — all four independently converged
on the same root cause behind the highest-frequency bug in this project:
listItems()/callAndGetItems()/store() each returned a different envelope
for 'here are your records' (bare array / bare array / {run,items}),
confirmed still recurring 3x even with the full (non-summarized) README
available, so more prose wasn't going to fix it.
dataset.listItems() and store() now return a value that's both a Promise
(await -> one page, { items, count, offset, limit, desc }) and an
AsyncIterable (for await -> every item, auto-paginated) -- the same dual
nature as apify-client's own PaginatedIterator, implemented via the same
mechanism (Object.defineProperty(promise, Symbol.asyncIterator, ...)), but
as ONE shared implementation (makePaginatedList) instead of apify-client's
own three independent, subtly-different copies of this trick.
This makes dataset.iterate() redundant -- removed, along with
DatasetIterateOptions and DEFAULT_ITERATE_BATCH. actor.callAndGetItems()
and dataset.inferFields() updated for the new page shape (no behavior
change -- both only ever consumed a single page).
Verified beyond typecheck: a standalone runtime smoke test confirms the
dual nature actually works (await resolves to one page; for-await
auto-paginates across multiple pages; a call with no limit makes exactly
one HTTP request instead of over-fetching). tests/binding-smoke.ts and
tests/sandbox-isolation.ts updated for the new shapes.
Explicitly NOT adopted from apify-client: its curried client.actor(id).call()
shape (this binding's flat options-object convention already fixed a
confirmed confusion bug this session and stays), and its own internal
inconsistencies (mixed positional/options args, waitForFinish vs waitSecs
naming split for the same concept).
Breaking change for any script written against the old bare-array
listItems()/store() shape -- every script is freshly generated per run
(one script per Actor run, no shipped callers), so no migration path is
needed.
A/B eval (run 2, apify-mcp-server evals/workflows) shows code mode's worth-it point isn't item count alone but item count x per-item payload size, given the sandbox's fixed ~20K token round-trip overhead: - 5-item single lookup: +78% slower, +46% more tokens (loses) - 100-record filter/sort/aggregate: -35% faster, -19% fewer tokens (wins) - 20-item fan-out over per-item web pages: -59% faster, -75% fewer tokens (wins decisively even at low item count) Encode this as concrete thresholds in both the actor.json description (agent-facing at discovery time) and README (agent-facing after fetch-actor-details).
…prose Council review (Pike, Davis, Hotz) flagged the README as over-explained: same facts stated 2-4x across sections with circular cross-references. - Remove 'Limitations' section (meta.origin/meta.actorRunId telemetry attribution) entirely — internal plumbing irrelevant to a caller deciding how to use this Actor. - Merge 'Limits & failure modes' into Output — both described the same exitCode/statusMessage vs run-status distinction, cross-referencing each other in a circle. - 'No imports' facts now live once in Permissions & safety; How it works just points there instead of restating. - Split the nested-run bullet (was one sentence doing four jobs) into two. - 'Worth it only for bulk work' bullets -> table. - Dual Promise/AsyncIterable explanation for listItems/store stated once (apify binding section); Recipes and inline comments now just reference it. - Fixed a real inconsistency: Output's terminal-status list was missing ABORTED (present in the Recipes poll-loop code right below it). 273 -> 221 lines, same information.
- What it does: drop 'Code Mode' naming and the TypeScript-SyntaxError aside (JS-only is already stated in the Input field description). - Calling this Actor: drop the raw-API POST/nextStep alternative — the call-actor example is the one path that matters here. - Learn more: drop the Code Mode design PR link, keep just the MCP Server link. - Also dropped the earlier free-text-judgment table row per feedback.
MCP server now hardcodes this Actor's ID to always return the full README, instead of trusting a self-declared actor.json flag any Actor could set to opt itself out of the auto-generated summary.
What
code-runtime— a single-purpose Apify Actor that runs one JS script perinvocation inside a sandboxed
workerdV8 isolate, exposing a typedapifybinding (
store,actor,run,dataset,keyValueStore) overfetch.Lets an MCP Code-Mode agent chain multiple Apify operations (search, run an
Actor, read its dataset, filter/aggregate) inside one sandboxed script
instead of many round-trip tool calls. Egress locked to
*.apify.com; noNode/npm imports; results land in the run's dataset as
{ stdout, stderr, exitCode, statusMessage }.Why
Backs Code Mode for the Apify MCP Server. Self-contained — the generic
call-actorMCP tool is enough, no dedicated MCP-server tool needed; thisActor's own
actor.jsonis the whole contract (schema, description,defaults).
Data: does Code Mode actually help?
Ran a paired A/B eval (apify-mcp-server's
evals/workflowsharness) — sametask, same agent/judge model, one arm using regular MCP Actor/storage tools
directly, one arm required to route the work through this Actor. 5 repeats
per arm per task, 3 task shapes:
Conclusion, now encoded directly in the Actor's description/README:
round-trip overhead (~20K tokens) isn't paid back.
multiple sub-resources with a sizeable payload each (visiting pages,
chaining Actor calls) — every skipped round trip avoids funneling a whole
raw document through the model.
Notable engineering decisions
realFetchallowlist bypass, aredirect-following bypass, blocked WebSocket/EventSource egress, dropped
nodejs_compat(nonode:netraw-socket egress, noprocess.envtokenleak).
methods to match the Apify API's own naming (
get,call,callAndGetItems,waitForFinish,inferFields,keyValueStore,top-level
apify.store({search})); added inline schema-check nudges,dangling-promise and curried-shape warnings after tracing recurring agent
mistakes; redesigned
dataset.listItems()/store()to be dualPromise/AsyncIterable (one page on
await, auto-paginated onfor await), removed the now-redundantdataset.iterate()— after a4-voice council review (Torvalds/Hotz/Pike/Hoare).
bloat: cut an internal-telemetry section, de-duplicated facts stated 2-4x
across sections, trimmed to what a caller actually needs.
Testing
pnpm typecheckon every push/PR../test.sh: live-Actor probe (tests/binding-smoke.ts,tests/sandbox-isolation.ts) viaapify call.change: 0 sandbox execution errors across 23
apify--code-runtimecalls,0 uses of the removed
.iterate(), 0 curried-shape misuse.