Skip to content

feat: implement the code mode runtime Actor - #1

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

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

Conversation

@MQ37

@MQ37 MQ37 commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

For reviewers: apify-mcp-server PRs #1123 and #1124 exercise this Actor through Code Mode.

What

code-runtime runs one JavaScript program per Actor invocation inside a sandboxed workerd isolate. It exposes a typed apify binding for Store, Actor runs, datasets, and key-value stores, then writes { stdout, stderr, exitCode, statusMessage } to the default dataset.

User code can fetch only http(s)://apify.com and *.apify.com. Internal API calls use a separate env.INTERNAL_API service binding and never expose the platform token-bearing capability to user code.

Why

This provides Code Mode for the Apify MCP Server without adding a dedicated MCP tool. The runtime now closes the reviewed capability-theft, SSRF, redirect, prototype-poisoning, path-injection, token-exposure, and concurrent run-limit bypasses.

Execution safeguards support maximum spawned runs, aggregate committed charge, and default child-run timeout. Spawned run IDs are scoped to the current script; unfinished runs are best-effort aborted when the script throws.

Syntax errors in generated user code become diagnostic dataset output with exitCode: 1 and statusMessage: "Failed to compile: ...". Infrastructure failures still fail the Actor run.

Data: does Code Mode actually help?

Ran a paired A/B eval using apify-mcp-server's evals/workflows harness: the same task, agent, and judge model were used for both arms. One arm used regular MCP Actor/storage tools directly; the other routed work through this Actor. Each arm ran 5 repeats across 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, about 20 pages each) 20 places × about 20 pages -59% faster, -75% fewer tokens — wins decisively

Conclusion, encoded in the Actor description and README:

  • Don't use it for a single lookup under about 10 items; the sandbox's round-trip overhead, about 20K tokens, is not paid back.
  • Use it for filtering, sorting, or aggregating 50+ dataset records.
  • Use it at modest counts, about 10+, when work fans out over multiple sub-resources with sizeable payloads, such as visiting pages or chaining Actor calls. Each skipped round trip avoids sending a raw document through the model.

Testing

  • pnpm run typecheck
  • pnpm build
  • pnpm run test — 44 unit tests
  • pnpm run test:integration — 10 real-workerd tests with a local API mock
  • ./test.sh — live Actor binding and sandbox probes

The latest changes also remove redundant comments while preserving security and ordering invariants.

@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.
@github-actions github-actions Bot added the tested Temporary label used only programatically for some analytics. label 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.
@MQ37
MQ37 requested review from RobertCrupa and jirispilka July 19, 2026 14:03
@RobertCrupa

Copy link
Copy Markdown

I tested it and tried to break it, but it held up :D

However, there is an inacurate comment that should be fixed before merging:

Issue

The }-escape in entrypoint.sh works and ES post-order runs guard → usercode → runner body, so user top-level code executes before runner.ts:29 claims. A script can call claimRealFetch() first:

// run-code input
}
globalThis.__rf = (await import('./guard.js')).claimRealFetch();
;{
→ run FAILS with realFetch already claimed - guard.js imported out of order (runner is starved). And a claim-only probe returns rf_type=function rf_is_fn=true.

So the comments at guard.ts:18-25 and runner.ts:17-18 ("runner.js … always claims first") are inverted. User code claims first.

Fix direction (either):

  • Claim realFetch inside guard.js at guard-eval time and expose it to runner.js through a channel user code cannot import (e.g. a module-private closure), dropping the standing claimRealFetch export entirely; or
  • Run user code as a new Function('apify', 'console', userCode) body invoked from runner.ts after line 29, so there is no module-scope window and no importable claimRealFetch.

Either removes both the false comment and the self-DoS. Worth adding a sandbox-isolation.ts case that exercises the claimRealFetch path to keep this covered.

PR #1 review comment (2026-07-21, issuecomment-5037390847) found the
claimRealFetch() one-shot handoff added in 5461aee didn't guarantee runner.ts
claims first. entrypoint.sh splices `code` verbatim (no escaping) into
`export async function run(apify, console) { <code> }`; a bare `}` in `code`
closes that function early, and everything after runs as ordinary
MODULE-SCOPE code in usercode.js. ES module evaluation order puts that ahead
of runner.ts's own top-level code (usercode.js is the import evaluated
immediately before runner.ts's own body runs), so injected top-level code
using `(await import('./guard.js')).claimRealFetch()` reliably claimed the
unrestricted fetch first, making runner.ts's own claim get null and throw --
crashing the whole Actor run. Verified live against the deployed Actor before
this fix: workerd exits with 'Uncaught Error: realFetch already claimed —
guard.js imported out of order', run FAILS.

Root cause isn't import order, it's that claimRealFetch() was claimable at
all during module evaluation, before any request. Fix: gate it on genuine
request handling instead. guard.ts adds requestHandlingStarted (false until
markRequestHandlingStarted() is called) and claimRealFetch() refuses (returns
null without consuming the resource) until that flag is set. runner.ts calls
markRequestHandlingStarted() + claims realFetch as the first, synchronous
statements of the /run path in its fetch handler -- before any `await`, so no
attacker-scheduled microtask from usercode.js's module scope can race it.
Module evaluation (guard.js, usercode.js, runner.ts's own top level) always
completes before workerd dispatches the first request, so this holds
regardless of any module-graph ordering, unlike the previous design.

realFetch itself moved from a module-top-level const (claimed at runner.ts's
own top-level, which is what lost the race) to a module-level
definite-assigned `let`, assigned inside the /run handler before any of the
functions that close over it run.

Also fixes the stale comments at guard.ts (claimRealFetch) and runner.ts
(top-of-file + requireRealFetch) asserting import order made runner.ts claim
first -- disproved by the live PoC above.

Adds tests/fixtures/realfetch-escape.js: a regression probe using the exact
escape shape from the review comment (real top-level await, not an async
IIFE -- top-level await is what forces usercode.js's evaluation to fully
settle before runner.ts's own top-level code runs; an IIFE's internal await
doesn't carry that guarantee). Wired into test.sh as a new live check
asserting `apify call`'s own exit status (this probe's run() body is an
empty no-op post-escape, so it has no captured console to report a sentinel
through, unlike the existing binding-smoke/sandbox-isolation probes).

Verified against the real deployed Actor, before/after, same probe:
- before (reverted worker/*.ts, same probe file): Actor run FAILS, workerd
  crash log matches the predicted 'guard.js imported out of order' message.
- after (this commit): Actor run SUCCEEDS, exitCode 0, statusMessage
  'Script completed' -- same as running an empty script.
Full suite (binding-smoke, sandbox-isolation, this new regression probe) run
live via ./test.sh: 19/19, 17/17, and the regression probe all pass.
@MQ37

MQ37 commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

@RobertCrupa Thank you! Should be fixed now and I also added this regressions test.

@jirispilka jirispilka left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the changes!

This PR is getting slightly out of hand.
If you please take a look at the security issue by Claude.
If valid, let's fix it and merge.

For the rest of the comments, please create an issue and we can fix it in the next PR.

Comment thread worker/guard.ts

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm sorry for AI copy paste, but I think this explanation is better than mine.
I found it using Opus. I've tried to verify it usgin Favle and GPT 5.6 Sol. both refused :D. But Kimi 3 was ok with it :)

Security invariant bypass: exported gate setter lets user code steal the one-shot realFetch

Verified live against commit 8930b95.

Issue

markRequestHandlingStarted() is exported from worker/guard.ts, while usercode.js is statically imported by worker/runner.ts and evaluated in the same module realm. User code that escapes the generated export async function run(...) { ... } wrapper can therefore import guard.js, open the gate itself, and consume the unrestricted one-shot fetch:

}
const guard = await import('./guard.js');
guard.markRequestHandlingStarted();
globalThis.__stolenRealFetch = guard.claimRealFetch();
;{

claimRealFetch() returns the real, un-allowlisted fetch function rather than null. When /run subsequently executes, the runner's own claim receives null and throws:

realFetch already claimed.

The /run request returns HTTP 500, and the curl -f invocation in worker/entrypoint.sh fails the Actor run. This is a deterministic run failure with the current code.

The documented invariant in worker/guard.ts is therefore false:

A claim attempted before that (legitimate or injected) gets null without consuming the resource, so the real claim still succeeds afterward.

That statement holds only when user code calls claimRealFetch() without first invoking the exported setter. An injected caller that flips the gate first consumes the one-shot.

The shipped tests/fixtures/realfetch-escape.js regression exercises only the naive claim without the gate flip, so it passes while leaving this path uncovered.

Security impact

The confirmed impact is:

  • User code can acquire a capability intended to remain available only to trusted runner code.
  • User code can consume that capability before the runner and deterministically fail the Actor run.
  • The guard's documented security invariant does not hold.

Unrestricted egress or SSRF is not confirmed on the pinned workerd version. workerd rejects asynchronous I/O during global-scope module evaluation:

Disallowed operation called within global scope. Asynchronous I/O
(ex: fetch() or connect()) ... are not allowed within global scope.

The stolen function cannot therefore make a request from module scope. It also cannot be used inside run(), because the runner fails while claiming realFetch before it invokes run().

This means there is no demonstrated exfiltration path today. However, during request handling, guard.js is the only hostname restriction on fetch; worker/config.capnp permits public, private, and local destinations. If global-scope I/O behavior changes through a workerd upgrade or compatibility/configuration drift, a usable stolen fetch would provide unrestricted egress and SSRF access to internal or metadata addresses.

The issue is best characterized as a security invariant bypass with deterministic self-DoS and latent unrestricted-egress risk. It should be fixed before the runtime leaves experimental status.

Fix

Make ordering the security boundary: trusted runner code must consume the capability before any user-controlled module is evaluated.

1. Simplify worker/guard.ts

Delete requestHandlingStarted and the exported markRequestHandlingStarted(). Keep claimRealFetch() as a plain one-shot:

const realFetch = globalThis.fetch.bind(globalThis);

let unclaimedRealFetch: typeof realFetch | null = realFetch;

export function claimRealFetch(): typeof realFetch | null {
    const fetchFn = unclaimedRealFetch;
    unclaimedRealFetch = null;
    return fetchFn;
}

2. Remove the static user-code import

Remove this import from worker/runner.ts:

import { run } from './usercode.js';

3. Claim first, then dynamically import user code

Call requireRealFetch() as the first statement after /run route validation, before any await, callback, or user-module evaluation. Dynamically import usercode.js only after the claim:

realFetch = requireRealFetch();

try {
    const { run } = await import('./usercode.js');
    await run(
        makeApifyBinding(token, apiV2, env.PARENT_ORIGIN),
        captureConsole,
    );
} catch (err) {
    // Existing user-error handling.
}

The resulting ordering is:

/run route validated
  -> runner consumes realFetch
  -> usercode.js evaluates
  -> any user claim receives null

This ordering is load-bearing and should be documented in both guard.ts and runner.ts. No await, user callback, or user-module evaluation may be introduced between route validation and the trusted claim.

Important workerd parsing behavior

Dynamic import does not defer parsing of modules embedded in config.capnp on workerd 1.20260402.1. A syntax error in usercode.js still kills workerd during service startup, before /run:

service main: Uncaught SyntaxError ... at usercode.js:2:6

The grep 'usercode.js' compile-failure branch in worker/entrypoint.sh must remain. It is the only available detection point for this failure and converts the startup error into the expected succeeded Actor run with a compile-failure dataset item.

Behavior change

After the fix, user module-scope code evaluates during the /run request rather than during worker startup. Module-scope guarded fetch() is consequently allowed where workerd previously rejected it as global-scope I/O.

This is security-neutral as long as the claim-before-import ordering holds: user code receives only the locked, allowlisted globalThis.fetch, while the unrestricted one-shot has already been consumed by the runner.

Verification

The fix was validated end-to-end on workerd 1.20260402.1:

  • The original attack calls a setter that no longer exists. Its resulting TypeError is caught as a user error, /run returns 200, and no outbound request occurs.
  • A strengthened escape fixture confirms that no gate setter is exported and claimRealFetch() returns null; the result has exitCode: 0.
  • The sandbox-isolation probe passes all 17 checks through the dynamic-import path.
  • /health does not consume the one-shot.
  • A second /run receives HTTP 500 with realFetch already claimed., preserving the documented single-run assumption.
  • Syntax-error user code kills workerd during startup and names usercode.js; the entrypoint detection matches it.
  • Type-checking and compilation pass.

Tests to add

Strengthen tests/fixtures/realfetch-escape.js

The fixture should assert both required security properties. A thrown assertion rejects the dynamic import and produces exitCode: 1, making either regression visible:

}
const guard = await import('./guard.js');

if ('markRequestHandlingStarted' in guard) {
    throw new Error('Security regression: request gate setter is exported');
}

const stolen = guard.claimRealFetch();
if (stolen !== null) {
    throw new Error('Security regression: unrestricted fetch was claimable');
}
;{

Expected result: a succeeded Actor run whose output dataset item has exitCode: 0.

Add a syntax-error fixture

Add an intentionally invalid fixture such as:

const = ;

Expected result: the Actor run succeeds and its output dataset contains exitCode: 1 and Failed to compile.

Assert dataset output

Tests must assert the pushed dataset item rather than merely checking that the Actor process survived:

apify call -f "$input_json" -o

Do not combine -o with --json; Apify CLI 1.7.1 treats --output-dataset and --json as mutually exclusive.

Run the complete verification:

pnpm run typecheck
pnpm build
sh -n worker/entrypoint.sh
sh -n test.sh
./test.sh

Expected results:

  • Binding smoke probe passes.
  • Sandbox isolation passes all 17 checks.
  • Strengthened realFetch escape fixture produces exitCode: 0.
  • Syntax-error fixture produces the expected compile-failure dataset item.
  • /health does not consume the one-shot.
  • A second /run is rejected with realFetch already claimed.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex always refused to test this for me, but when I asked Claude Opus, it initially declined, and when I asked him again to do it, it worked fine :)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider addition of tests into CI:

Claude:

Testing gap: no behavioral tests in CI — the sandbox boundary itself is ungated

CI runs typecheck only (.github/workflows/typecheck.yml — one job, pnpm run typecheck). Every behavioral test in test.sh requires apify push + apify call + a token, so nothing about the
security boundary runs in CI. Locally, the guard logic has no token-free test at all: the allowlist paths of isAllowedHost/validateUrl are only exercised live on-platform
(tests/sandbox-isolation.ts), and guardedFetch's redirect re-validation — the entire reason that function exists — has no test of any kind, live or local.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Added token-free unit tests and real-workerd integration tests with a local API mock to CI.

Comment thread tests/binding-smoke.ts

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most smoke-test checks don’t validate returned values

tests/binding-smoke.ts:14-23 marks a check as passed whenever its callback does not throw. Most callbacks only return a diagnostic string, so incorrect or empty results can still produce ALL_TESTS_PASSED.

For example, keyValueStore.get never verifies the round-tripped values, and the paginated dataset.listItems check never confirms that both pushed items were yielded. An early-stop regression could therefore
pass silently.

Please assert the expected results, for example:

  const obj = await apify.keyValueStore.get({ storeId, key: 'obj' }) as { hello?: unknown } | null;
  if (obj?.hello !== 'world') {
      throw new Error(`kvs.get returned ${JSON.stringify(obj)}`);
  }

  let n = 0;
  for await (const _ of apify.dataset.listItems({ datasetId, limit: 1 })) n++;
  if (n !== 2) throw new Error(`expected 2 items, iterated ${n}`);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Smoke tests now verify KVS round-trips, dataset contents, pagination, run statuses, and returned values.

Comment thread worker/runner.ts

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider aborting still-active tracked runs when handling a script exception

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Tracked non-terminal child runs are best-effort aborted when the user script throws; cleanup errors do not hide the original failure.

Comment thread worker/runner.ts
// Runs an Actor (same as call(), waitForFinishSecs defaults to 60) and returns its
// dataset items in one call. Calls createRun() directly rather than through
// `actor.call()` — same underlying request, no self-reference to `actor` needed.
callAndGetItems: async ({ actorId, input, fields, limit, ...runOpts }: RunAndGetItemsOptions): Promise<{ run: RunRecord; items: ApifyRecord[] }> => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

callAndGetItems can return undocumented partial results

If the called Actor is still RUNNING when the 60-second API wait expires, callAndGetItems() immediately reads its dataset and returns whatever has been written so far. The returned items may therefore be empty or partial while the child run continues.

Please document this behavior in both README.md and docs/API.md.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. README and docs/API.md now explain that callAndGetItems can return empty or partial results while the child run remains non-terminal.

Comment thread worker/runner.ts Outdated
// Shared by run() and start(): both POST /acts/:id/runs, differing only in whether
// waitForFinish is set. Records the created run's ID in startedRunIds so run.abort()
// can be scoped to runs this script itself started (see the run.abort definition below).
call: (opts: StartOptions): Promise<RunRecord> => createRun({ waitForFinishSecs: 60, ...opts }),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 60-second API limit is repeated. Consider defining DEFAULT_WAIT_FOR_FINISH_SECS = 60 and reuse it

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Defined DEFAULT_WAIT_FOR_FINISH_SECS = 60 and reused it for call, callAndGetItems, and waitForFinish.

Comment thread worker/runner.ts

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

apify.actor.start, actor.call, and actor.callAndGetItems expose timeoutSecs, maxTotalChargeUsd, and maxItems, but all are optional and simply forwarded by createRun() when present.

The Code Runtime’s 900-second timeout applies only to the Code Runtime run. Likewise, waitForFinishSecs: 60 only limits how long the API request waits; it neither stops nor limits the cost of the Actor it started. A script can also start an unrestricted number of Actors, with no shared spending budget.

Can we add Code Runtime-level safeguards:

  • Add a configurable maximum number of Actor runs per script.
  • Apply a configurable default timeoutSecs when one is not supplied.
  • Provide an execution-level spending limit and propagate an appropriate maxTotalChargeUsd to each started Actor.
  • Reject new Actor starts once the configured run-count or spending budget is exhausted.
  • Document that waitForFinishSecs is not an execution or cost limit.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Added configurable run-count, default-timeout, and execution-spending limits with synchronous reservations, rejection, rollback, and documentation.

@jirispilka

Copy link
Copy Markdown

I haven't tested it yet, will do tomorrow

MQ37 added 6 commits August 4, 2026 10:41
…+ tests

Addresses PR #1 review (jirispilka, 2026-08-01):

- worker/guard.ts, worker/runner.ts, worker/config.capnp: the previous fix
  (8930b95) gated runner's internal-API fetch behind an exported
  markRequestHandlingStarted/claimRealFetch pair. That gate was itself an
  export, so escaped usercode.js top-level code could call it directly and
  steal the unrestricted fetch before runner's own claim ran. Root fix: move
  internal-API access off any module export entirely, onto a workerd env
  binding (env.INTERNAL_API, wired to its own outbound network service).
  `env` only ever reaches the genuinely-dispatched fetch(request, env) call —
  nothing at module-evaluation time can obtain a reference to it, exported or
  not. guard.js now exports only pure, safe allowlist helpers. Verified live
  against the original exploit PoC plus two additional attack variants in a
  local workerd sandbox: all now fail closed with no capability leak.
- config.capnp: split ambient outbound (public-only, for guarded fetch) from
  the internal-API outbound (public/private/local, internal use only) as a
  second, independent defense layer against SSRF even if the JS-level guard
  had a bug.
- worker/runner.ts: abort Actor runs a script started but left non-terminal
  when the script itself throws (best-effort, failures reported not thrown).
  Extracted DEFAULT_WAIT_FOR_FINISH_SECS. Added optional execution-level
  safeguards (maxActorRuns, maxTotalChargeUsd, defaultTimeoutSecs) so a
  script can't start unbounded runs or authorize unbounded spend.
- tests/unit/guard.test.ts (new, vitest): token-free CI coverage for
  isAllowedHost/validateUrl/nextRedirectInit/guardedFetch's redirect
  re-validation, plus a regression test that guard.js exports nothing beyond
  known-safe helpers. CI now runs typecheck + this suite on every PR.
- tests/binding-smoke.ts: every check now asserts the actual returned value
  instead of only checking the callback didn't throw.
- tests/fixtures/realfetch-escape.js, test.sh: updated regression probe and
  comments for the new architecture (the old exploit call now throws a plain
  TypeError at module eval, reported as a normal compile-failure diagnostic).
- docs/API.md, README.md, .actor/actor.json: documented the new execution
  limits, and that callAndGetItems/actor.call/run.waitForFinish can return
  partial/non-terminal results — waitForFinishSecs bounds the API wait only,
  not the child run's cost or duration.
…egration tests

Scout-loop iteration 1 (4 parallel reviewers: security, code-quality, coding-standards,
feature-impact) found real regressions in the previous commit:

- worker/runner.ts: the previous commit's rewrite DROPPED the guard.js side-effect
  import entirely while removing the old claimRealFetch import — guard.js was never
  imported by anything in the real module graph, so its fetch/WebSocket/EventSource
  overrides never ran. Every script's plain fetch()/WebSocket had unrestricted egress.
  Confirmed live (workerd), fixed by restoring `import './guard.js'` as the first
  import, with a comment on why the order matters and pointing at the regression test
  (tests/sandbox-isolation.ts's existing 'block https://example.com/' check, now backed
  by a new real-workerd integration test).
- worker/runner.ts's createRun(): maxActorRuns/maxTotalChargeUsd were checked
  synchronously but only recorded after their POST resolved, so N concurrent calls
  (this Actor's own documented 'Bounded parallel fan-out' recipe) all read
  pre-reservation counters and all passed. Now reserves synchronously before the first
  await, rolls back on failure. Covered by a new integration test that actually races
  5 concurrent actor.start() calls against a real workerd process.
- worker/runner.ts: froze the default export object so escaped module-scope code can't
  reassign .fetch to a wrapper that captures the real request/env on the next genuine
  dispatch — closes a residual capability-theft vector of the same class already fixed
  twice in this file, found by feature-impact review of the seam.
- .actor/actor.json: maxTotalChargeUsd allowed 0, which parsePositiveNumberEnv treats as
  'unset' (unlimited) — the exact inverse of a user setting a bash safety budget. Schema
  now requires a positive value, matching its sibling fields.
- worker/guard.ts: guardedFetch's redirect-hop counter was a public parameter on an
  exported function (any escaped-code caller could pass a pre-inflated value to defeat
  MAX_REDIRECT_HOPS) — split into an unexported recursive helper.
- worker/runner.ts: renamed the ad-hoc TERMINAL_STATUSES (which included the
  non-terminal ABORTING) to DONE_TRACKING_STATUSES with a comment on why, to stop it
  reading as (and drifting from) docs/API.md's actual terminal-status set.
- makeApifyBinding/pushOutput: switched to object parameters (5 positional args each,
  over this codebase's own >3-params convention).
- tests/integration/ (new): boots a real workerd process against the actual compiled
  worker/*.js with a local mock internal-API server — closes the gap unit tests
  structurally can't (guard.js correct-in-isolation vs. actually-wired-in;
  Limits enforcement holding under real concurrency). Runs in CI, still fully offline.
- Also: RunRecord.defaultDatasetId typed properly (removed an unjustified cast),
  Limits fields use `| undefined` per this codebase's own convention, vitest configs
  added (was silently double-running the suite via build byproducts), @types/node
  added scoped to tests/integration only (kept separate from the rest of the program,
  which intentionally models workerd's no-nodejs_compat environment).
…ess bugs

Scout-loop iteration 2 (4 fresh reviewers) found real bugs in the previous round's fixes:

- worker/guard.ts: validateUrl called the bare `new URL(...)`, resolving whatever
  globalThis.URL currently is. A plain script (no module-scope escape needed) could
  do `globalThis.URL = class extends URL { get hostname() { return 'apify.com'; } }`
  to make the allowlist check believe a disallowed host was apify.com, while the
  actual fetch still went to the real target string. Fixed by capturing the real URL
  constructor (RealURL) before usercode.js can ever run, same pattern as realFetch.
  Verified live against workerd, plus a new unit test regression case.
- worker/runner.ts's createRun(): a script-supplied maxTotalChargeUsd of NaN (or
  another non-finite/non-positive value) flowed straight into committedChargeUsd's
  arithmetic. NaN is absorbing (NaN - x and x - NaN both stay NaN), so a single bad
  call permanently corrupted the running total and defeated the whole execution-level
  budget check for the rest of the script — the catch block's rollback can't recover
  it either (subtracting NaN from NaN is still NaN). Now validated (finite, > 0)
  before it touches any shared state. Verified live against workerd.
- worker/runner.ts: runner.ts's own Object.freeze calls run (via import order) AFTER
  usercode.js's module body, so escaped top-level code could shadow the global
  Object.freeze to a no-op before any of them ever fire — defeating the 'frozen so
  the script can't reassign this' guarantee on the apify binding/console/default
  export. Fixed by exporting a pre-captured realObjectFreeze from guard.ts (same
  capture-before-usercode-runs pattern), used throughout runner.ts instead of the
  bare global.
- tests/integration/harness.ts: the mock API's route matching used
  `req.url.endsWith('/runs')`, which breaks the moment a real request carries a
  query string — createRun() always attaches one (waitForFinish/timeout/memory/
  maxTotalChargeUsd), so requests silently fell through to the wrong mock response
  branch, masking real behavior in several 'passing' tests. Fixed to match on
  pathname. Also: removed a test that quietly made a live network call to
  https://apify.com (flaky, contradicted this suite's own 'offline' claim — the
  allow-path is already covered offline by the unit suite); added a new test proving
  createRun()'s rollback actually fires on a real API rejection (via a new
  failNextRunCreate() hook on the mock); every acquired resource (mock server, temp
  dir, workerd child process) now released via try/finally on every path, including
  waiting for the child to actually exit before returning.
- worker/runner.ts, tests/binding-smoke.ts: fixed a stale comment left after the
  previous round's TERMINAL_STATUSES -> DONE_TRACKING_STATUSES rename, and corrected
  DONE_TRACKING_STATUSES's own comment to name its real call sites (createRun/
  waitForFinish, not abortTrackedRuns).
- tsconfig.integration.json now extends tsconfig.json instead of duplicating its
  compiler options.
- .actor/actor.json: dropped a restated numeric-floor sentence from
  maxTotalChargeUsd's description that broke the sibling fields' pattern of trusting
  the JSON Schema constraint alone.
…hod poisoning

Scout-loop iteration 3 (4 fresh reviewers, all converging independently on the same root
cause): rounds 1-2 fixed *reassignment* of security-relevant globals (globalThis.URL,
globalThis.Object.freeze) by capturing a reference before usercode.js can run. That does
NOT protect a shared PROTOTYPE method or static function — a captured constructor's
.prototype IS the same mutable object as the live global's .prototype, and a captured
static function reference only helps if it's captured before usercode.js runs, which
several call sites weren't.

Three real, empirically-verified bypasses, no module-scope escape needed at all — ordinary
script-body code:

- worker/guard.ts's isAllowedHost called hostname.toLowerCase()/.replace()/.endsWith()
  directly. `String.prototype.endsWith = () => true;` in a normal script defeated the
  entire *.apify.com allowlist for every fetch() call. Verified live: fetch to
  http://example.com/ succeeded.
- worker/runner.ts's run.abort ownership check did `startedRunIds.has(runId)`.
  `Set.prototype.has = () => true;` let a script abort ANY run, not just ones it started
  — a real capability escalation, not just an SSRF variant. Also affected
  DONE_TRACKING_STATUSES.has (runner.ts) and REDIRECT_STATUSES.has (guard.ts).
- worker/runner.ts's new maxTotalChargeUsd validation did `Number.isFinite(...)`.
  `Number.isFinite = () => true;` resurrected the exact NaN-budget-corruption bug the
  previous round's fix was written to close. Math.min's budget clamp had the same gap.

Fix: consolidated every builtin guard.ts's or runner.ts's security decisions depend on
into one explicit, documented capture block at the top of guard.ts (RealURL,
realObjectFreeze, and now setHas/numberIsFinite/mathMin plus guard.ts-private string-method
captures for isAllowedHost) — captured once, before usercode.js can ever run, invoked via
.call()/direct reference rather than through the poisonable value.method() syntax. This is
the audit surface for the whole trust boundary now, instead of three independently
discovered special cases.

Added a dedicated regression test for each captured primitive (mirrors the existing RealURL
test): realObjectFreeze/setHas/numberIsFinite/mathMin/isAllowedHost's string methods, each
proven to still work correctly after the corresponding global is poisoned. Added an
integration test for the maxTotalChargeUsd validation end to end. All three original
exploits re-verified live against real workerd post-fix: fail closed as expected.
…t, JSON.stringify

Scout-loop iteration 4 (4 fresh reviewers doing an exhaustive line-by-line sweep of
guard.ts/runner.ts) found the round-3 capture-block pattern was correct but incomplete —
applied only to the specific bypasses previous rounds happened to discover live, not
re-derived as a full audit. Found and fixed, all verified live against real workerd:

- worker/runner.ts's buildUrl() had its OWN bare `new URL(...)` call, never touched by
  round 2's guard.ts fix — building the URL for the unrestricted, token-bearing internal
  API request. A script reassigning globalThis.URL could redirect every apify.actor.*/
  dataset.*/keyValueStore.* call to an attacker-controlled host, with the real
  Authorization: Bearer <APIFY_TOKEN> header attached. Highest-severity finding across all
  4 rounds. Fixed by exporting guard.ts's captured RealURL and using it in runner.ts too.
- guard.ts's validateUrl read `url.hostname`/`url.protocol` directly — PROTOTYPE ACCESSOR
  (getter) poisoning (`Object.defineProperty(URL.prototype, 'hostname', { get: () =>
  'apify.com' })`) defeated the allowlist even with RealURL's constructor captured, since
  captured-constructor.prototype IS the same live, mutable prototype object. Same root
  cause as round 3's method-poisoning bugs, one syntactic shape removed (accessor vs.
  method). Fixed by capturing the getter FUNCTIONS themselves and invoking via .call().
- worker/runner.ts used the bare, poisonable `encodeURIComponent` for every internal API
  path segment (actorId/runId/datasetId/storeId/key, 13 call sites) — poisoning it to a
  no-op let a script inject unescaped path segments (`../../key-value-stores/SECRET/
  records/token`) into the token-bearing internal API request.
- worker/runner.ts's pushOutput used bare `JSON.stringify` to build the run's own trusted
  result item — poisoning it let a script forge {exitCode:0, statusMessage:'Script
  completed'} over a real crash, defeating the documented 'callers detect a failed script
  via this field' guarantee. Same gap in apiCall's/keyValueStore.set's request bodies
  (lower severity, already-attacker-owned data, fixed for consistency).
- worker/runner.ts's response.ok reads (apiCall, keyValueStore.get, pushOutput) and
  parsePositiveNumberEnv's Number() coercion had the same bare-global gap.

All consolidated into guard.ts's existing capture block (now also: realNumber,
encodeUriComponent, jsonStringify, urlHostname, urlProtocol, responseOk, responseStatus,
and an exported RealURL for runner.ts's own use), with a dedicated poisoning-regression
test per primitive in tests/unit/guard.test.ts (44 tests total, up from 39) plus a new
integration test for the maxTotalChargeUsd-via-createRun path.

Every 4th-round exploit re-verified live against real workerd post-fix: encodeURIComponent
poisoning now produces a correctly-escaped path, JSON.stringify poisoning no longer masks a
real script crash, and a poisoned URL constructor no longer redirects the internal API call
away from its real host.
@MQ37

MQ37 commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for detailed review. Personally, I don’t think it’s worth obsessing over making this security boundary perfect. There will probably always be another bypass unless we add an infrastructure-level egress proxy, which doesn’t seem worth it for this Actor.

That said, I summoned an army of agents to review the implementation from security, quality, standards, and blast-radius angles. We went through four iterative rounds and fixed the findings, then hardened the runtime further so we can ship this confidently.

Your points about cancelling tracked runs and handling syntax errors were especially useful. I originally wanted to ship a minimal version first, validate whether people actually use this approach, and iterate afterward. We can ship those improvements now instead, so I implemented them. Thanks.

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.

4 participants