Skip to content

feat: implement the code mode runtime Actor#1

Open
MQ37 wants to merge 37 commits into
masterfrom
feat/code-mode-runtime
Open

feat: implement the code mode runtime Actor#1
MQ37 wants to merge 37 commits into
masterfrom
feat/code-mode-runtime

Conversation

@MQ37

@MQ37 MQ37 commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

What

code-runtime — a single-purpose Apify Actor that runs one JS script per
invocation inside a sandboxed workerd V8 isolate, exposing a typed apify
binding (store, actor, run, dataset, keyValueStore) over fetch.
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; no
Node/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-actor MCP tool is enough, no dedicated MCP-server tool needed; this
Actor's own actor.json is the whole contract (schema, description,
defaults).

Data: does Code Mode actually help?

Ran a paired A/B eval (apify-mcp-server's evals/workflows harness) — same
task, 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:

Task Items Runtime vs standard
Single small lookup (5 Instagram posts) 5 +78% slower, +46% more tokens — loses
Bulk filter/sort/aggregate over one dataset (100 Google Maps places → top 10) 100 -35% faster, -19% fewer tokens — wins
Fan-out over many sub-resources (20 places → visit each website) 20 places × ~20 pages -59% faster, -75% fewer tokens — wins decisively

Conclusion, now encoded directly in the Actor's description/README:

  • Don't use it for a single lookup under ~10 items — the sandbox's own
    round-trip overhead (~20K tokens) isn't paid back.
  • Worth it once filtering/sorting/aggregating 50+ dataset records.
  • Worth it even at modest counts (~10+) when the task fans out over
    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

  • Sandbox hardening: closed a realFetch allowlist bypass, a
    redirect-following bypass, blocked WebSocket/EventSource egress, dropped
    nodejs_compat (no node:net raw-socket egress, no process.env token
    leak).
  • API shape, iterated from live eval failures (not guessed): renamed
    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 dual
    Promise/AsyncIterable (one page on await, auto-paginated on
    for await), removed the now-redundant dataset.iterate() — after a
    4-voice council review (Torvalds/Hotz/Pike/Hoare).
  • README, also council-reviewed (Pike/Davis/Hotz) for wall-of-text
    bloat: cut an internal-telemetry section, de-duplicated facts stated 2-4x
    across sections, trimmed to what a caller actually needs.

Testing

  • CI: pnpm typecheck on every push/PR.
  • ./test.sh: live-Actor probe (tests/binding-smoke.ts,
    tests/sandbox-isolation.ts) via apify call.
  • Eval pilot re-run against the rebuilt Actor after the dual-mode API
    change: 0 sandbox execution errors across 23 apify--code-runtime calls,
    0 uses of the removed .iterate(), 0 curried-shape misuse.

@MQ37
MQ37 force-pushed the feat/code-mode-runtime branch 3 times, most recently from aed71df to 3286abf Compare June 23, 2026 17:51
@MQ37
MQ37 force-pushed the feat/code-mode-runtime branch from 3286abf to 096ea5b Compare June 24, 2026 13:04
MQ37 added 4 commits July 7, 2026 13:11
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.
@jirispilka

jirispilka commented Jul 10, 2026

Copy link
Copy Markdown

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.
To be honest I spent a while wanting to push back on the hand-rolled apify binding and to use apify-client or CLI but after some back and forth and after understanding the constraint (neither runs in workerd without nodejs_compat, which reopens exactly the holes this PR closes) I dropped it. The binding is justified. So everything below is refinement, not a rethink.

The rest is written mostly by Claude with my edits.

Things I want to agree on

1. Write the Actor's own code in TypeScript.
The image is already built on the platform, and that build already runs Node (Dockerfile stage 1). Compiling worker/*.ts*.js there is one command in a build that already exists — workerd embeds the compiled JS either way. This is ~300 lines of security-critical code with no CI and no type-checking right now. I don't see the case for plain JS here — what am I missing?

2. code input should be JS only — drop "TypeScript" everywhere.
The user script is fetched at container runtime and wrapped verbatim into usercode.js (entrypoint.sh:32-38); nothing transpiles it and workerd doesn't strip types, so a type annotation today is a SyntaxError at load. Clean split: Actor source = TS (build-time); user code = JS only — keeps the runtime image Node-free. Remove "TypeScript" from the README, actor.json, and the input-schema description.

3. Give the runner a real status message.
exitCode 0/1 is the only signal, and the most common failure an LLM will cause — bad syntax — doesn't even reach it. A parse error in usercode.js fails the worker load before the try/catch at runner.js:283, so the run dies as an opaque 10s health-check timeout with no diagnostic. Two small changes:

  • Import the user module inside the try/catch (await import('./usercode.js') instead of the static import at runner.js:13) so a SyntaxError is catchable.
  • Add a statusMessage field next to {stdout, stderr, exitCode}: "Script completed" / "Script threw: …" / "Failed to compile: …".

4. The binding docs have to live in the Actor.
Right now the real guide lives in the MCP repo (code_docs_content.ts). Anyone connecting this Actor from a non-Apify client gets only the README. For an Actor whose whole input is "write code against a binding you can't see," the reference needs to be self-contained here — README + docs/API.md + a proper input-schema description — and the MCP tool should mirror it, not own it.

Stop renaming the Apify vocabulary and this is not bike-shedding, it is important for LLMs

The binding renames a lot of apify-client methods and params. LLMs already know apify-client — and our own MCP server uses it (actor().start(input, {memory, timeout}), run().waitForFinish({waitSecs}), keyValueStore().listKeys()). Every rename is a fact the model has to unlearn from a docs page instead of recall for free, and it directly hurts point 4: the closer the surface is to apify-client, the less any docs are even needed.

Rule I'd propose: match the API / apify-client surface by default; rename only where the original name is actively wrong (store→search). The single-options-object shape (get({ storeId, key }) instead of positional) is a fine, separate choice — keep it. This is only about names.

Note: I like kvs rather than keyValueStore but still, at public surface, it is better to provide clear names.

Binding (now) apify-client / API Proposed Note
actor.search({ query }) store().list({ search }) keep actor.search; param querysearch the one good rename
actor.getDetails() actor().get() actor.get
actor.run() (start+wait) actor().call() actor.call avoids a 3rd verb; matches CLI apify call + MCP call-actor
actor.start() actor().start() keep
actor.runAndGetItems() (none) keep; verb → actor.callAndGetItems genuine helper
run.get / run.abort same keep
run.wait() run().waitForFinish() run.waitForFinish see wait-param note below
run.getLog() log().get() keep limit-tail is a useful extra
kvs.* keyValueStore keyValueStore
kvs.get() keyValueStore().getRecord()/getValue() keyValueStore.getRecord returns the value directly today — pick getRecord vs getValue and match the return shape
kvs.set() keyValueStore().setRecord() keyValueStore.setRecord
kvs.list() keyValueStore().listKeys() keyValueStore.listKeys
dataset.listItems / pushItems same keep
dataset.iterate / dataset.getSchema (none) keep; maybe getSchemainferFields avoids confusion with the Actor's declared schema

The run-option params

Here's the full picture across every layer, because it's the confusing bit:

Concept MCP tool binding (now) REST query apify-client
memory (MB) memory memoryMbytes memory memory
timeout (s) timeout timeoutSecs timeout timeout
wait (s) waitSecs waitForFinishSecs waitForFinish waitForFinish (.start()) / waitSecs (.call(), run().waitForFinish())
max items maxItems maxItems maxItems maxItems
max charge maxTotalChargeUsd maxTotalChargeUsd maxTotalChargeUsd maxTotalChargeUsd
  • memory / timeout: drop the Mbytes/Secs suffixes — use memory / timeout. The units belong in the param description, not the name. (memoryMbytes/timeoutSecs isn't invented — it's the Run object / defaultRunOptions vocabulary — but the binding is setting run input, so match the input side: REST + client + our MCP all use memory/timeout.)
  • wait: rename waitForFinishSecswaitForFinish. Your binding maps it straight to the REST waitForFinish query param (runner.js:68-76,100-103), so this is a plain passthrough — match it and drop the invented Secs.
  • One thing to not do: don't try to unify this with the MCP's waitSecs. They're genuinely different: REST/binding waitForFinish is a single server-side block capped at 60s; the MCP's waitSecs is a client-side poll ceiling that returns early. Apify has two names for two behaviors on purpose — the binding is the block (waitForFinish), the MCP tool is the poll (waitSecs). Leave both as they are.

Findings

Security — I'd fix these before it leaves experimental

  • Redirects escape the allowlist. guard.js only checks the initial URL; fetch defaults to redirect: 'follow' (guard.js:47). An allowlisted *.apify.com 302 to any host gets followed out. Fix: redirect: 'manual' + re-check isAllowedHost on each Location.
  • globalThis.fetch isn't locked. It's a plain assignment (guard.js:32) while WebSocket/EventSource use defineProperty({writable:false}). Lock fetch the same way — not exploitable today, just inconsistent and fragile.

Robustness

  • Syntax errors crash the run instead of returning exitCode:1 (same fix as point 3).
  • No retries / 429 / backoff. apiCall (runner.js:39-49) is a single fetch that throws on any non-2xx — a multi-call script dies on the first transient 500 or rate-limit. This is the real cost of not using apify-client.
  • No spend ceiling, no orphan cleanup. No default timeout in actor.json; inner actor.start loops don't force maxTotalChargeUsd/maxItems; and a killed run leaves its sub-runs billing (no cascade-abort).
  • run.abort({ runId }) takes any runId (runner.js:105) — account-wide, not scoped to runs this script started. Low severity, but it's an unreviewed capability.

Contract / maintainability

  • No CI. The probes only run via test.sh → live apify push + token. Add tsc --noEmit + lint once it's TS.

Minor

  • package.json:4 description is stale ("via the Worker Loader API" — runner.js says the opposite).
  • actor.json output description says { stdout, stderr }, drops exitCode.
  • apify/console aren't frozen — a script can reassign console.log and corrupt its own capture. Trivial Object.freeze.
  • The egress claim oversells: even a perfect allowlist doesn't stop exfil (actor.start({input}) → any Actor with open internet; dataset/KV writes + webhooks). Say "no direct fetch-based exfil from this container," not "contained."

Confirmed good — leave these alone
Correct host label-matching (parsed via new URL().hostname, look-alikes tested); WebSocket/EventSource removed; single-use container, no isolate pooling; token held in a closure, unreachable from the script (process undefined); binding method surface matches the guide; not standby; docs/API.md exists.

MQ37 added 13 commits July 13, 2026 14:12
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.
MQ37 added 2 commits July 14, 2026 13:21
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).
@github-actions github-actions Bot added t-ai Issues owned by the AI team. tested Temporary label used only programatically for some analytics. labels Jul 16, 2026
MQ37 added 10 commits July 16, 2026 13:12
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.
@MQ37 MQ37 changed the title feat: per-run workerd sandbox for MCP Code Mode feat: implement the code mode runtime Actor Jul 17, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

t-ai Issues owned by the AI team. tested Temporary label used only programatically for some analytics.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants