diff --git a/cmd/bee/cmd/cmd.go b/cmd/bee/cmd/cmd.go
index 59a8a8297e3..cf7ba16b0bf 100644
--- a/cmd/bee/cmd/cmd.go
+++ b/cmd/bee/cmd/cmd.go
@@ -86,6 +86,22 @@ const (
optionNameMinimumGasTipCap = "minimum-gas-tip-cap"
optionNameGasLimitFallback = "gas-limit-fallback"
optionNameP2PWSSEnable = "p2p-wss-enable"
+ optionNameWasmExecuteEnable = "wasm-execute-enable"
+ optionNameWasmWorkers = "wasm-workers"
+ optionNameWasmExecTimeout = "wasm-exec-timeout"
+ optionNameWasmMaxModuleSize = "wasm-max-module-size"
+ optionNameWasmMemory = "wasm-memory"
+ optionNameWasmMaxMemory = "wasm-max-memory"
+ optionNameWasmHostCalls = "wasm-host-calls"
+ optionNameWasmMaxHostCalls = "wasm-max-host-calls"
+ optionNameWasmHostBytes = "wasm-host-bytes"
+ optionNameWasmMaxHostBytes = "wasm-max-host-bytes"
+ optionNameWasmExecDepth = "wasm-exec-depth"
+ optionNameWasmMaxExecDepth = "wasm-max-exec-depth"
+ optionNameWasmMaxResponseHeaders = "wasm-max-response-headers"
+ optionNameWasmMaxResponseHeaderBytes = "wasm-max-response-header-bytes"
+ optionNameWasmRequestHeaders = "wasm-request-headers"
+ optionNameWasmMaxEnvBytes = "wasm-max-env-bytes"
optionP2PWSSAddr = "p2p-wss-addr"
optionNATWSSAddr = "nat-wss-addr"
optionAutoTLSDomain = "autotls-domain"
@@ -336,6 +352,25 @@ func (c *command) setAllFlags(cmd *cobra.Command) {
cmd.Flags().Uint64(optionNameMinimumGasTipCap, 0, "minimum gas tip cap in wei for transactions, 0 means use suggested gas tip cap")
cmd.Flags().Uint64(optionNameGasLimitFallback, 500_000, "gas limit fallback when estimation fails for contract transactions")
cmd.Flags().Bool(optionNameP2PWSSEnable, false, "Enable Secure WebSocket P2P connections")
+ cmd.Flags().Bool(optionNameWasmExecuteEnable, false, "enable the experimental WASM execute endpoint")
+ cmd.Flags().Int(optionNameWasmWorkers, 0, "maximum number of concurrent WASM executions, 0 means min(number of CPUs, 8)")
+ cmd.Flags().Duration(optionNameWasmExecTimeout, 10*time.Second, "wall-clock watchdog timeout for a single WASM execution")
+ cmd.Flags().Uint64(optionNameWasmMaxModuleSize, 16*1024*1024, "maximum size in bytes of a WASM module that may be executed")
+ cmd.Flags().Uint64(optionNameWasmMemory, 32*1024*1024, "default linear memory limit in bytes for a single WASM execution")
+ cmd.Flags().Uint64(optionNameWasmMaxMemory, 256*1024*1024, "maximum linear memory limit in bytes a request may ask for")
+ cmd.Flags().Uint64(optionNameWasmHostCalls, 64, "default number of swarm host calls a single WASM execution may make")
+ cmd.Flags().Uint64(optionNameWasmMaxHostCalls, 1024, "maximum number of swarm host calls a request may ask for")
+ cmd.Flags().Uint64(optionNameWasmHostBytes, 32*1024*1024, "default total bytes swarm host calls of a single WASM execution may move")
+ cmd.Flags().Uint64(optionNameWasmMaxHostBytes, 256*1024*1024, "maximum total bytes moved by swarm host calls a request may ask for")
+ cmd.Flags().Uint64(optionNameWasmExecDepth, 4, "default maximum nesting depth of swarm_execute calls")
+ cmd.Flags().Uint64(optionNameWasmMaxExecDepth, 8, "maximum nesting depth of swarm_execute calls a request may ask for")
+ // No default/maximum pair for these: the request-header overrides exist so a
+ // caller can bound risk it is exposed to, and a caller is not exposed to the
+ // response header budget.
+ cmd.Flags().Uint64(optionNameWasmMaxResponseHeaders, 32, "maximum number of response headers a WASM module may set")
+ cmd.Flags().Uint64(optionNameWasmMaxResponseHeaderBytes, 8*1024, "maximum total size in bytes of the response headers a WASM module may set")
+ cmd.Flags().StringSlice(optionNameWasmRequestHeaders, nil, "request headers exposed to a WASM module as CGI HTTP_* variables; replaces the built-in list. Authorization, Proxy-Authorization and Cookie are never exposed")
+ cmd.Flags().Uint64(optionNameWasmMaxEnvBytes, 16*1024, "maximum total size in bytes of the request metadata exposed to a WASM module")
cmd.Flags().String(optionP2PWSSAddr, ":1635", "p2p wss address")
cmd.Flags().String(optionNATWSSAddr, "", "WSS NAT exposed address")
cmd.Flags().String(optionAutoTLSDomain, p2pforge.DefaultForgeDomain, "autotls domain")
diff --git a/cmd/bee/cmd/start.go b/cmd/bee/cmd/start.go
index a423c6bbcaf..b399da4c675 100644
--- a/cmd/bee/cmd/start.go
+++ b/cmd/bee/cmd/start.go
@@ -363,6 +363,22 @@ func buildBeeNode(ctx context.Context, c *command, cmd *cobra.Command, logger lo
WarmupTime: c.config.GetDuration(optionWarmUpTime),
WelcomeMessage: c.config.GetString(optionWelcomeMessage),
WhitelistedWithdrawalAddress: c.config.GetStringSlice(optionNameWhitelistedWithdrawalAddress),
+ WasmExecuteEnable: c.config.GetBool(optionNameWasmExecuteEnable),
+ WasmWorkers: c.config.GetInt(optionNameWasmWorkers),
+ WasmExecTimeout: c.config.GetDuration(optionNameWasmExecTimeout),
+ WasmMaxModuleSize: c.config.GetUint64(optionNameWasmMaxModuleSize),
+ WasmMemory: c.config.GetUint64(optionNameWasmMemory),
+ WasmMaxMemory: c.config.GetUint64(optionNameWasmMaxMemory),
+ WasmHostCalls: c.config.GetUint64(optionNameWasmHostCalls),
+ WasmMaxHostCalls: c.config.GetUint64(optionNameWasmMaxHostCalls),
+ WasmHostBytes: c.config.GetUint64(optionNameWasmHostBytes),
+ WasmMaxHostBytes: c.config.GetUint64(optionNameWasmMaxHostBytes),
+ WasmExecDepth: c.config.GetUint64(optionNameWasmExecDepth),
+ WasmMaxExecDepth: c.config.GetUint64(optionNameWasmMaxExecDepth),
+ WasmMaxResponseHeaders: c.config.GetUint64(optionNameWasmMaxResponseHeaders),
+ WasmMaxResponseHeaderBytes: c.config.GetUint64(optionNameWasmMaxResponseHeaderBytes),
+ WasmRequestHeaders: c.config.GetStringSlice(optionNameWasmRequestHeaders),
+ WasmMaxEnvBytes: c.config.GetUint64(optionNameWasmMaxEnvBytes),
})
return b, err
diff --git a/go.mod b/go.mod
index 80b5129f2ac..e1916d38d9f 100644
--- a/go.mod
+++ b/go.mod
@@ -49,7 +49,7 @@ require (
golang.org/x/crypto v0.48.0
golang.org/x/net v0.50.0
golang.org/x/sync v0.19.0
- golang.org/x/sys v0.41.0
+ golang.org/x/sys v0.44.0
golang.org/x/term v0.40.0
golang.org/x/time v0.12.0
gopkg.in/yaml.v2 v2.4.0
@@ -59,6 +59,8 @@ require (
resenje.org/web v0.4.3
)
+require github.com/tetratelabs/wazero v1.12.0
+
require (
filippo.io/bigmod v0.1.1-0.20260103110540-f8a47775ebe5 // indirect
filippo.io/keygen v0.0.0-20260114151900-8e2790ea4c5b // indirect
diff --git a/go.sum b/go.sum
index be253587a4f..d9fad93eb11 100644
--- a/go.sum
+++ b/go.sum
@@ -956,6 +956,8 @@ github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cb
github.com/tdewolff/minify/v2 v2.7.3/go.mod h1:BkDSm8aMMT0ALGmpt7j3Ra7nLUgZL0qhyrAHXwxcy5w=
github.com/tdewolff/parse/v2 v2.4.2/go.mod h1:WzaJpRSbwq++EIQHYIRTpbYKNA3gn9it1Ik++q4zyho=
github.com/tdewolff/test v1.0.6/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE=
+github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
+github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
github.com/tklauser/go-sysconf v0.3.5/go.mod h1:MkWzOF4RMCshBAMXuhXJs64Rte09mITnppBXY/rYEFI=
github.com/tklauser/go-sysconf v0.3.6/go.mod h1:MkWzOF4RMCshBAMXuhXJs64Rte09mITnppBXY/rYEFI=
@@ -1260,8 +1262,8 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
-golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
+golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 h1:O1cMQHRfwNpDfDJerqRoE2oD+AFlyid87D40L/OkkJo=
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
diff --git a/openapi/Swarm.yaml b/openapi/Swarm.yaml
index a0b3e296b8f..059c988970a 100644
--- a/openapi/Swarm.yaml
+++ b/openapi/Swarm.yaml
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
- version: 8.1.0
+ version: 8.2.0
title: Bee API
description: "API endpoints for interacting with the Swarm network, supporting file operations, messaging, and node management"
@@ -211,6 +211,172 @@ paths:
default:
description: Default response
+ "/@/{address}":
+ parameters:
+ - in: path
+ name: address
+ schema:
+ $ref: "SwarmCommon.yaml#/components/schemas/SwarmReference"
+ required: true
+ description: Swarm address reference of the WASM module
+ - $ref: "SwarmCommon.yaml#/components/parameters/SwarmWasmMemoryLimit"
+ - $ref: "SwarmCommon.yaml#/components/parameters/SwarmWasmEntrypoint"
+ - $ref: "SwarmCommon.yaml#/components/parameters/SwarmWasmHostCallsLimit"
+ - $ref: "SwarmCommon.yaml#/components/parameters/SwarmWasmHostBytesLimit"
+ - $ref: "SwarmCommon.yaml#/components/parameters/SwarmWasmDepthLimit"
+ get: &executeOperation
+ summary: "Execute a WASM module stored in Swarm"
+ description: >
+ Downloads the WebAssembly module at the given address, runs it in a
+ sandbox with the request body as its input and returns what it wrote as
+ output. Disabled by default; the node operator enables it with
+ `--wasm-execute-enable`.
+
+
+ Every HTTP method is accepted, including ones not listed here, and the
+ module decides how to react to it. The one exception is `OPTIONS`, which
+ the node answers itself as a CORS preflight so it never reaches
+ untrusted code.
+
+
+ **Request metadata** reaches the module CGI-style, through the
+ environment: `REQUEST_METHOD`, `SCRIPT_NAME`, `PATH_INFO`,
+ `QUERY_STRING`, `REQUEST_URI`, `CONTENT_TYPE`, `CONTENT_LENGTH` and the
+ allowlisted request headers as `HTTP_*`. A trailing path is available at
+ `/@/{address}/{path}` and arrives as `PATH_INFO`, which is empty on the
+ bare form. The host environment is never inherited, and
+ `Authorization`, `Proxy-Authorization` and `Cookie` are never forwarded.
+ The environment is capped; overflow is a 431.
+
+
+ The representation of the result is negotiated with the `Accept` header:
+ `application/json` returns the full execution envelope,
+ `application/octet-stream` and `text/html` return the raw output. Any
+ other media type is rejected with 406. A wildcard `Accept` returns the
+ envelope unless the module set response metadata of its own, in which
+ case it returns the raw output with the module's content type — which is
+ what lets a browser load a module's stylesheets and images.
+
+
+ A verdict on the program itself (`trap`, `invalid-module`) is reported
+ as 400 with the verdict in the `swarm-wasm-status` header; a failure
+ local to this node is reported as 500.
+
+
+ **Node access.** Besides `wasi_snapshot_preview1`, a module may import a
+ host module named `swarm` to read and write Swarm data:
+ `swarm_bytes_get`, `swarm_bytes_put`, `swarm_chunk_get`,
+ `swarm_chunk_put` and `swarm_execute`. The full ABI, including the
+ result codes and the caller-provides-buffer convention, is documented in
+ `pkg/compute/README.md`. Importing a name that module does not define is
+ rejected before the module runs.
+
+
+ **Shaping the response.** The same host module offers
+ `swarm_response_status` and `swarm_response_header`, which let a module
+ set the HTTP status and headers rather than having them derived from the
+ verdict and the caller's `Accept`. These need no node access and are
+ available even when it is switched off. A module cannot set
+ `Swarm-Wasm-*`, `Access-Control-*`, `Set-Cookie`, the origin-wide
+ security headers or the hop-by-hop headers. Response metadata is
+ committed only on a clean run: a module that traps sets no headers, just
+ as it stores nothing.
+
+
+ Uploads are paid for by the postage batch the module passes to a put
+ call, which it can only have received as input; the node resolves it
+ exactly as `POST /chunks` does, so this path grants no authority the
+ HTTP API does not already grant. One execution gets one upload session
+ and therefore one batch. Uploads are **deferred**: when the response
+ returns, the data is in the local upload store but not yet acknowledged
+ by the network, and the session is committed only if the execution
+ succeeded — a module that traps leaves nothing behind.
+
+
+ **Experimental.** Output is not reproducible across nodes: a module
+ reaching the node observes state local to it, and this engine enforces
+ no deterministic gas budget. What a module may make the node do is
+ bounded by the host-call, host-byte and depth budgets instead.
+ The endpoint runs untrusted code in the node's own process and
+ should not be enabled on a public gateway.
+ tags:
+ - Execute
+ requestBody:
+ required: false
+ description: Input handed to the module
+ content:
+ application/octet-stream:
+ schema:
+ type: string
+ format: binary
+ responses:
+ "200":
+ description: The module ran and produced a result
+ headers:
+ "swarm-wasm-status":
+ $ref: "SwarmCommon.yaml#/components/headers/SwarmWasmStatus"
+ content:
+ application/octet-stream:
+ schema:
+ type: string
+ format: binary
+ text/html:
+ schema:
+ type: string
+ application/json:
+ schema:
+ $ref: "SwarmCommon.yaml#/components/schemas/WasmExecutionResponse"
+ "400":
+ $ref: "SwarmCommon.yaml#/components/responses/400"
+ "403":
+ $ref: "SwarmCommon.yaml#/components/responses/403"
+ "404":
+ $ref: "SwarmCommon.yaml#/components/responses/404"
+ "406":
+ $ref: "SwarmCommon.yaml#/components/responses/406"
+ "413":
+ $ref: "SwarmCommon.yaml#/components/responses/413"
+ "429":
+ $ref: "SwarmCommon.yaml#/components/responses/429"
+ "500":
+ $ref: "SwarmCommon.yaml#/components/responses/500"
+ default:
+ description: Default response
+ head: *executeOperation
+ post: *executeOperation
+ put: *executeOperation
+ patch: *executeOperation
+ delete: *executeOperation
+
+ "/@/{address}/{path}":
+ parameters:
+ - in: path
+ name: address
+ schema:
+ $ref: "SwarmCommon.yaml#/components/schemas/SwarmReference"
+ required: true
+ description: Swarm address reference of the WASM module
+ - in: path
+ name: path
+ schema:
+ type: string
+ required: true
+ allowEmptyValue: true
+ description: >
+ Path handed to the module as `PATH_INFO`, prefixed with `/`. May be
+ empty, which is how `/@/{address}/` differs from `/@/{address}`.
+ - $ref: "SwarmCommon.yaml#/components/parameters/SwarmWasmMemoryLimit"
+ - $ref: "SwarmCommon.yaml#/components/parameters/SwarmWasmEntrypoint"
+ - $ref: "SwarmCommon.yaml#/components/parameters/SwarmWasmHostCallsLimit"
+ - $ref: "SwarmCommon.yaml#/components/parameters/SwarmWasmHostBytesLimit"
+ - $ref: "SwarmCommon.yaml#/components/parameters/SwarmWasmDepthLimit"
+ get: *executeOperation
+ head: *executeOperation
+ post: *executeOperation
+ put: *executeOperation
+ patch: *executeOperation
+ delete: *executeOperation
+
"/bytes/{address}":
get:
summary: "Retrieve data by reference"
diff --git a/openapi/SwarmCommon.yaml b/openapi/SwarmCommon.yaml
index ffcbbac3b8e..9a20ef61aad 100644
--- a/openapi/SwarmCommon.yaml
+++ b/openapi/SwarmCommon.yaml
@@ -1,6 +1,6 @@
openapi: 3.0.3
info:
- version: 5.0.0
+ version: 5.1.0
title: Common Data Types
description: Common data structures and types used throughout the Bee API
@@ -656,6 +656,47 @@ components:
pattern: "^[A-Fa-f0-9]{64}$"
example: "36b7efd913ca4cf880b8eeac5093fa27b0825906c600685b6abdd6566e6cfe8f"
+ WasmStatus:
+ type: string
+ description: >
+ Outcome of a WASM execution. `ok`, `trap` and `invalid-module` are
+ verdicts on the program itself; `host-error` is a failure local to the
+ node and says nothing about the program.
+ enum:
+ - ok
+ - trap
+ - invalid-module
+ - host-error
+
+ WasmExecutionResponse:
+ type: object
+ properties:
+ status:
+ $ref: "#/components/schemas/WasmStatus"
+ output:
+ type: string
+ format: byte
+ description: Bytes the module wrote to its output
+ trapMessage:
+ type: string
+ description: Explanation of a non-`ok` status
+ httpStatus:
+ type: integer
+ description: >
+ HTTP status the module asked for through `swarm_response_status`,
+ absent when it asked for none. In this representation it is reported
+ rather than applied, so that a client can tell the module's own 404
+ from the node's.
+ headers:
+ type: object
+ additionalProperties:
+ type: array
+ items:
+ type: string
+ description: >
+ Response headers the module set through `swarm_response_header`,
+ grouped by name. Reported rather than applied, as with `httpStatus`.
+
PublicKey:
type: string
pattern: "^[A-Fa-f0-9]{66}$"
@@ -959,6 +1000,8 @@ components:
type: integer
isWarmingUp:
type: boolean
+ isWasmEnabled:
+ type: boolean
StatusPeersResponse:
type: object
@@ -1081,6 +1124,11 @@ components:
schema:
$ref: "SwarmCommon.yaml#/components/schemas/Uid"
+ SwarmWasmStatus:
+ description: "Outcome of the execution"
+ schema:
+ $ref: "#/components/schemas/WasmStatus"
+
SwarmFeedIndex:
description: "The index of the found update"
schema:
@@ -1288,6 +1336,61 @@ components:
description: >
Indicates whether the uploaded data should be sent to the network immediately or deferred. Default: deferred (true)
+ SwarmWasmMemoryLimit:
+ in: header
+ name: swarm-wasm-memory-limit
+ schema:
+ type: integer
+ format: int64
+ required: false
+ description: >
+ Maximum linear memory in bytes the module may allocate. Values above the
+ limit configured on the node are clamped to it. Defaults to the node
+ configured value.
+
+ SwarmWasmEntrypoint:
+ in: header
+ name: swarm-wasm-entrypoint
+ schema:
+ type: string
+ required: false
+ description: >
+ Exported function to invoke. Defaults to the WASI command entrypoint
+ `_start`. Applies to the outermost module only: a module run through
+ `swarm_execute` is always started as a WASI command.
+
+ SwarmWasmHostCallsLimit:
+ in: header
+ name: swarm-wasm-host-calls-limit
+ schema:
+ type: integer
+ required: false
+ description: >
+ Maximum number of `swarm` host calls the execution may make, shared
+ across nested executions. May only lower the node's configured value.
+
+ SwarmWasmHostBytesLimit:
+ in: header
+ name: swarm-wasm-host-bytes-limit
+ schema:
+ type: integer
+ required: false
+ description: >
+ Maximum total payload, in bytes, that `swarm` host calls may move in
+ either direction, shared across nested executions. May only lower the
+ node's configured value.
+
+ SwarmWasmDepthLimit:
+ in: header
+ name: swarm-wasm-depth-limit
+ schema:
+ type: integer
+ required: false
+ description: >
+ Maximum number of execution levels a `swarm_execute` call tree may
+ reach, the outermost execution included, so `1` permits no nesting at
+ all. May only lower the node's configured value.
+
SwarmCache:
in: header
name: swarm-cache
@@ -1360,6 +1463,24 @@ components:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetails"
+ "403":
+ description: Forbidden
+ content:
+ application/problem+json:
+ schema:
+ $ref: "#/components/schemas/ProblemDetails"
+ "406":
+ description: Not Acceptable
+ content:
+ application/problem+json:
+ schema:
+ $ref: "#/components/schemas/ProblemDetails"
+ "413":
+ description: Payload Too Large
+ content:
+ application/problem+json:
+ schema:
+ $ref: "#/components/schemas/ProblemDetails"
"429":
description: Too many requests
content:
diff --git a/pkg/api/api.go b/pkg/api/api.go
index acd838a3ff6..2fc5d449c13 100644
--- a/pkg/api/api.go
+++ b/pkg/api/api.go
@@ -27,6 +27,7 @@ import (
"github.com/ethereum/go-ethereum/common"
"github.com/ethersphere/bee/v2/pkg/accesscontrol"
"github.com/ethersphere/bee/v2/pkg/accounting"
+ "github.com/ethersphere/bee/v2/pkg/compute"
"github.com/ethersphere/bee/v2/pkg/crypto"
"github.com/ethersphere/bee/v2/pkg/feeds"
"github.com/ethersphere/bee/v2/pkg/file/pipeline"
@@ -94,12 +95,20 @@ const (
SwarmActPublisherHeader = "Swarm-Act-Publisher"
SwarmActHistoryAddressHeader = "Swarm-Act-History-Address"
+ SwarmWasmMemoryLimitHeader = "Swarm-Wasm-Memory-Limit"
+ SwarmWasmEntrypointHeader = "Swarm-Wasm-Entrypoint"
+ SwarmWasmHostCallsHeader = "Swarm-Wasm-Host-Calls-Limit"
+ SwarmWasmHostBytesHeader = "Swarm-Wasm-Host-Bytes-Limit"
+ SwarmWasmDepthHeader = "Swarm-Wasm-Depth-Limit"
+ SwarmWasmStatusHeader = "Swarm-Wasm-Status"
+
ImmutableHeader = "Immutable"
GasPriceHeader = "Gas-Price"
GasLimitHeader = "Gas-Limit"
ETagHeader = "ETag"
AuthorizationHeader = "Authorization"
+ AcceptHeader = "Accept"
AcceptEncodingHeader = "Accept-Encoding"
ContentTypeHeader = "Content-Type"
ContentDispositionHeader = "Content-Disposition"
@@ -194,6 +203,9 @@ type Service struct {
stamperStore storage.Store
pinIntegrity PinIntegrity
+ compute compute.Engine
+ executeConfig ExecuteConfig
+
syncStatus func() (bool, error)
swap swap.Interface
@@ -268,6 +280,8 @@ type ExtraOptions struct {
SyncStatus func() (bool, error)
NodeStatus *status.Service
PinIntegrity PinIntegrity
+ Compute compute.Engine
+ ExecuteConfig ExecuteConfig
}
func New(
@@ -379,6 +393,9 @@ func (s *Service) Configure(signer crypto.Signer, tracer *tracing.Tracer, o Opti
}
s.pinIntegrity = e.PinIntegrity
+
+ s.compute = e.Compute
+ s.executeConfig = e.ExecuteConfig
}
func (s *Service) SetProbe(probe *Probe) {
@@ -582,7 +599,8 @@ func (s *Service) corsHandler(h http.Handler) http.Handler {
SwarmPostageBatchIdHeader, SwarmPostageStampHeader, SwarmDeferredUploadHeader, SwarmRedundancyLevelHeader,
SwarmRedundancyStrategyHeader, SwarmRedundancyFallbackModeHeader, SwarmChunkRetrievalTimeoutHeader, SwarmLookAheadBufferSizeHeader,
SwarmFeedIndexHeader, SwarmFeedIndexNextHeader, SwarmSocSignatureHeader, SwarmOnlyRootChunk, GasPriceHeader, GasLimitHeader, ImmutableHeader,
- SwarmActHeader, SwarmActTimestampHeader, SwarmActPublisherHeader, SwarmActHistoryAddressHeader,
+ SwarmActHeader, SwarmActTimestampHeader, SwarmActPublisherHeader, SwarmActHistoryAddressHeader, SwarmWasmMemoryLimitHeader, SwarmWasmEntrypointHeader,
+ SwarmWasmHostCallsHeader, SwarmWasmHostBytesHeader, SwarmWasmDepthHeader, SwarmWasmStatusHeader,
}
allowedHeadersStr := strings.Join(allowedHeaders, ", ")
diff --git a/pkg/api/api_test.go b/pkg/api/api_test.go
index babd816dd06..348dd1dd765 100644
--- a/pkg/api/api_test.go
+++ b/pkg/api/api_test.go
@@ -26,6 +26,7 @@ import (
mockac "github.com/ethersphere/bee/v2/pkg/accesscontrol/mock"
accountingmock "github.com/ethersphere/bee/v2/pkg/accounting/mock"
"github.com/ethersphere/bee/v2/pkg/api"
+ "github.com/ethersphere/bee/v2/pkg/compute"
"github.com/ethersphere/bee/v2/pkg/crypto"
"github.com/ethersphere/bee/v2/pkg/feeds"
"github.com/ethersphere/bee/v2/pkg/file/pipeline"
@@ -131,6 +132,8 @@ type testServerOptions struct {
RedistributionAgent *storageincentives.Agent
NodeStatus *status.Service
PinIntegrity api.PinIntegrity
+ Compute compute.Engine
+ ExecuteConfig api.ExecuteConfig
WhitelistedAddr string
FullAPIDisabled bool
ChequebookDisabled bool
@@ -210,6 +213,8 @@ func newTestServer(t *testing.T, o testServerOptions) (*http.Client, *websocket.
Staking: o.StakingContract,
NodeStatus: o.NodeStatus,
PinIntegrity: o.PinIntegrity,
+ Compute: o.Compute,
+ ExecuteConfig: o.ExecuteConfig,
}
// By default bee mode is set to full mode.
diff --git a/pkg/api/execute.go b/pkg/api/execute.go
new file mode 100644
index 00000000000..07df965ac73
--- /dev/null
+++ b/pkg/api/execute.go
@@ -0,0 +1,430 @@
+// Copyright 2026 The Swarm Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package api
+
+import (
+ "errors"
+ "fmt"
+ "html"
+ "io"
+ "net/http"
+ "strings"
+
+ "github.com/ethersphere/bee/v2/pkg/compute"
+ "github.com/ethersphere/bee/v2/pkg/file/joiner"
+ "github.com/ethersphere/bee/v2/pkg/file/redundancy"
+ "github.com/ethersphere/bee/v2/pkg/jsonhttp"
+ "github.com/ethersphere/bee/v2/pkg/storage"
+ "github.com/ethersphere/bee/v2/pkg/swarm"
+ "github.com/ethersphere/bee/v2/pkg/topology"
+ "github.com/ethersphere/bee/v2/pkg/tracing"
+ "github.com/gorilla/mux"
+)
+
+// ExecuteConfig holds the operator-configured bounds for the execute endpoint.
+// Per-request headers may only lower a limit below its configured maximum.
+type ExecuteConfig struct {
+ MaxModuleSize uint64
+ DefaultMemory uint64
+ MaxMemory uint64
+ // Bounds on what a module may make the node do through the swarm host
+ // module. wazero has no gas metering, so these are what stop a module
+ // fetching or storing without end.
+ DefaultHostCalls uint64
+ MaxHostCalls uint64
+ DefaultHostBytes uint64
+ MaxHostBytes uint64
+ DefaultDepth uint64
+ MaxDepth uint64
+ // Bounds on the response metadata a module may set. These carry no
+ // default/maximum pair and no per-request header: that pattern exists so a
+ // caller can lower risk it is exposed to, and a caller is not exposed to
+ // this one.
+ MaxResponseHeaders uint64
+ MaxResponseHeaderBytes uint64
+ // RequestHeaders names the request headers forwarded to a module as CGI
+ // HTTP_* variables. Empty means defaultRequestHeaders; a non-empty value
+ // replaces that list outright, so an operator who widens the surface owns
+ // the decision. forbiddenRequestHeaders is enforced regardless.
+ RequestHeaders []string
+ // MaxEnvBytes bounds the derived environment. Zero means
+ // defaultMaxEnvBytes.
+ MaxEnvBytes uint64
+}
+
+// defaultMaxEnvBytes bounds the CGI environment when the operator has not set a
+// limit. Overflow is a 431 rather than a truncation: silently shortening the
+// environment would hand the guest a lie it cannot detect.
+const defaultMaxEnvBytes = 16 << 10
+
+// executeResponse is the structured (JSON) representation of an execution result.
+type executeResponse struct {
+ Status string `json:"status"`
+ Output []byte `json:"output"`
+ TrapMessage string `json:"trapMessage,omitempty"`
+ // HTTPStatus and Headers report what the module asked for through
+ // swarm_response_*. In this representation they are reported, never applied:
+ // a client that asked for the envelope asked to be told about the run, not to
+ // have its own transport reshaped by it. Applying them would also make "the
+ // module says 404" indistinguishable from "the node says module not found".
+ HTTPStatus int `json:"httpStatus,omitempty"`
+ Headers map[string][]string `json:"headers,omitempty"`
+}
+
+// envelopeFor renders the result as the JSON envelope both a 200 and a
+// program-fault 400 carry.
+func envelopeFor(res compute.Result) executeResponse {
+ return executeResponse{
+ Status: res.Status.String(),
+ Output: res.Output,
+ TrapMessage: res.TrapMessage,
+ HTTPStatus: res.Response.Status,
+ Headers: headerMap(res.Response),
+ }
+}
+
+// headerMap groups the guest's headers by name, preserving the order of repeats.
+func headerMap(meta compute.ResponseMeta) map[string][]string {
+ if len(meta.Headers) == 0 {
+ return nil
+ }
+ out := make(map[string][]string, len(meta.Headers))
+ for _, h := range meta.Headers {
+ name := http.CanonicalHeaderKey(h.Name)
+ out[name] = append(out[name], h.Value)
+ }
+ return out
+}
+
+// executeHandler downloads the WASM module addressed by {address}, runs it in the
+// sandbox with the request body as input, and renders the result negotiated on
+// the Accept header.
+//
+// The route accepts every HTTP method and hands the method to the module (see
+// compute.Request.Method), so the program decides how to react to it. OPTIONS is
+// the one exception: it is answered by the node so CORS preflight never reaches
+// untrusted code.
+func (s *Service) executeHandler(w http.ResponseWriter, r *http.Request) {
+ logger := tracing.NewLoggerWithTraceID(r.Context(), s.logger.WithName("execute").Build())
+
+ // We don't allow the OPTIONS method, as browsers use it for CORS preflight checks.
+ if r.Method == http.MethodOptions {
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+
+ vars := mux.Vars(r)
+
+ paths := struct {
+ Address swarm.Address `map:"address,resolve" validate:"required"`
+ }{}
+ if response := s.mapStructure(vars, &paths); response != nil {
+ response("invalid path params", logger, w)
+ return
+ }
+
+ // PATH_INFO is empty on the bare route and starts with "/" on the
+ // trailing-path one, which is the CGI rule: /@/a -> "", /@/a/ -> "/",
+ // /@/a/x/y -> "/x/y". mux hands back the decoded path, matching /bzz.
+ pathInfo := ""
+ if raw, ok := vars["path"]; ok {
+ pathInfo = "/" + raw
+ }
+
+ env := s.executeEnv(r, pathInfo)
+ maxEnv := s.executeConfig.MaxEnvBytes
+ if maxEnv == 0 {
+ maxEnv = defaultMaxEnvBytes
+ }
+ if uint64(envSize(env)) > maxEnv {
+ jsonhttp.RequestHeaderFieldsTooLarge(w, "request metadata exceeds maximum size")
+ return
+ }
+
+ headers := struct {
+ Memory *uint64 `map:"Swarm-Wasm-Memory-Limit"`
+ Entrypoint string `map:"Swarm-Wasm-Entrypoint"`
+ HostCalls *uint64 `map:"Swarm-Wasm-Host-Calls-Limit"`
+ HostBytes *uint64 `map:"Swarm-Wasm-Host-Bytes-Limit"`
+ Depth *uint64 `map:"Swarm-Wasm-Depth-Limit"`
+ }{}
+ if response := s.mapStructure(r.Header, &headers); response != nil {
+ response("invalid header params", logger, w)
+ return
+ }
+
+ // Negotiate the response representation up front so we can reject an
+ // unsupported Accept before doing any work.
+ format, explicitJSON, ok := negotiateExecuteFormat(r.Header.Get(AcceptHeader))
+ if !ok {
+ jsonhttp.NotAcceptable(w, "unsupported Accept media type")
+ return
+ }
+
+ lim := compute.Limits{
+ Memory: clampLimit(headers.Memory, s.executeConfig.DefaultMemory, s.executeConfig.MaxMemory),
+ Entrypoint: headers.Entrypoint,
+ MaxHostCalls: uint32(clampLimit(headers.HostCalls, s.executeConfig.DefaultHostCalls, s.executeConfig.MaxHostCalls)),
+ MaxHostBytes: clampLimit(headers.HostBytes, s.executeConfig.DefaultHostBytes, s.executeConfig.MaxHostBytes),
+ MaxDepth: uint32(clampLimit(headers.Depth, s.executeConfig.DefaultDepth, s.executeConfig.MaxDepth)),
+
+ MaxResponseHeaders: uint32(s.executeConfig.MaxResponseHeaders),
+ MaxResponseHeaderBytes: uint32(s.executeConfig.MaxResponseHeaderBytes),
+ }
+
+ // Download and reassemble the module bytes, capped at the configured maximum.
+ reader, l, err := joiner.New(r.Context(), s.storer.Download(true), s.storer.Cache(), paths.Address, redundancy.DefaultDownloadLevel)
+ if err != nil {
+ if errors.Is(err, storage.ErrNotFound) || errors.Is(err, topology.ErrNotFound) {
+ logger.Debug("execute: module not found", "address", paths.Address, "error", err)
+ jsonhttp.NotFound(w, "module not found")
+ return
+ }
+ logger.Debug("execute: joiner failed", "address", paths.Address, "error", err)
+ logger.Error(nil, "execute: joiner failed")
+ jsonhttp.InternalServerError(w, "could not read module")
+ return
+ }
+
+ maxModule := s.executeConfig.MaxModuleSize
+ if maxModule > 0 && l >= 0 && uint64(l) > maxModule {
+ jsonhttp.RequestEntityTooLarge(w, "module exceeds maximum size")
+ return
+ }
+
+ module, err := readCapped(reader, maxModule)
+ if err != nil {
+ if errors.Is(err, errTooLarge) {
+ jsonhttp.RequestEntityTooLarge(w, "module exceeds maximum size")
+ return
+ }
+ logger.Debug("execute: reading module failed", "address", paths.Address, "error", err)
+ logger.Error(nil, "execute: reading module failed")
+ jsonhttp.InternalServerError(w, "could not read module")
+ return
+ }
+
+ input, err := io.ReadAll(r.Body)
+ if err != nil {
+ logger.Debug("execute: reading request body failed", "error", err)
+ jsonhttp.InternalServerError(w, "could not read input")
+ return
+ }
+
+ // The host is per-request: any upload it opens belongs to this execution
+ // alone and is committed or dropped below.
+ host := s.newExecuteHost(r.Context(), logger, lim.HostBytes())
+
+ result, err := s.compute.Execute(r.Context(), compute.Request{
+ Module: module,
+ Method: r.Method,
+ Input: input,
+ Env: env,
+ Limits: lim,
+ Host: host,
+ })
+ if err != nil {
+ if cerr := host.Close(false); cerr != nil {
+ logger.Debug("execute: discarding upload session failed", "error", cerr)
+ }
+ if errors.Is(err, compute.ErrBusy) {
+ jsonhttp.TooManyRequests(w, "execution workers busy")
+ return
+ }
+ logger.Debug("execute: execution failed", "address", paths.Address, "error", err)
+ logger.Error(nil, "execute: execution failed")
+ jsonhttp.InternalServerError(w, "execution failed")
+ return
+ }
+
+ // Only a clean run commits its uploads; a trapped or rejected module leaves
+ // nothing behind.
+ if err := host.Close(result.Status == compute.StatusOK); err != nil {
+ logger.Debug("execute: closing upload session failed", "error", err)
+ logger.Error(nil, "execute: closing upload session failed")
+ jsonhttp.InternalServerError(w, "could not store uploaded data")
+ return
+ }
+
+ // A wildcard Accept defaults to the envelope, but a module that shaped its own
+ // response has expressed an opinion the wildcard has not contradicted. Honour
+ // it, so browsers and fetch() get the bytes the module meant to serve. A
+ // module that set nothing is untouched, which is what keeps this additive.
+ if format == formatJSON && !explicitJSON && !result.Response.Empty() {
+ format = formatModule
+ }
+
+ renderExecResult(w, format, result)
+}
+
+// renderExecResult writes the execution result in the negotiated representation.
+// The HTTP status is the program verdict's unless the module set its own, which
+// it can only do in the raw representations.
+func renderExecResult(w http.ResponseWriter, format string, res compute.Result) {
+ // Set first, so the guest's headers cannot end up overwriting it. The
+ // denylist forbids the name anyway; this is the second lock on that door.
+ w.Header().Set(SwarmWasmStatusHeader, res.Status.String())
+
+ switch res.Status {
+ case compute.StatusInvalidModule, compute.StatusTrap:
+ // Program's fault: deterministic bad request. res.Response is empty on
+ // these paths by construction, so nothing of the guest's is applied.
+ renderExecError(w, format, res)
+ return
+ case compute.StatusHostError:
+ jsonhttp.InternalServerError(w, "execution failed")
+ return
+ }
+
+ if format == formatJSON {
+ // Reported, not applied.
+ jsonhttp.OK(w, envelopeFor(res))
+ return
+ }
+
+ // A raw representation: the module owns the body, so it owns the headers
+ // describing it. Its Content-Type replaces the negotiated default.
+ switch format {
+ case formatHTML:
+ w.Header().Set(ContentTypeHeader, "text/html; charset=utf-8")
+ case formatOctet:
+ w.Header().Set(ContentTypeHeader, "application/octet-stream")
+ default: // formatModule: the guest decides, with a conservative fallback.
+ w.Header().Set(ContentTypeHeader, "application/octet-stream")
+ }
+ applyResponseHeaders(w, res.Response)
+
+ status := http.StatusOK
+ if res.Response.Status != 0 {
+ status = res.Response.Status
+ }
+ w.WriteHeader(status)
+ _, _ = w.Write(res.Output)
+}
+
+// applyResponseHeaders writes the guest's headers onto the response.
+//
+// The first occurrence of a name replaces whatever the node negotiated, and
+// later repeats accumulate, so a guest can both override Content-Type and send
+// several Link headers. The denylist is re-checked here: the engine already
+// enforces it, and an engine bug should not become a header leak.
+func applyResponseHeaders(w http.ResponseWriter, meta compute.ResponseMeta) {
+ seen := make(map[string]struct{}, len(meta.Headers))
+ for _, h := range meta.Headers {
+ if compute.DeniedResponseHeader(h.Name) {
+ continue
+ }
+ name := http.CanonicalHeaderKey(h.Name)
+ if _, ok := seen[name]; !ok {
+ w.Header().Del(name)
+ seen[name] = struct{}{}
+ }
+ w.Header().Add(name, h.Value)
+ }
+}
+
+// renderExecError writes a deterministic program-fault response in the
+// representation the client negotiated.
+//
+// The JSON envelope is the same one a 200 carries. For the raw representations a
+// client that asked for HTML gets HTML: answering a negotiated text/html request
+// with a JSON body, as this used to, is a content-type lie.
+func renderExecError(w http.ResponseWriter, format string, res compute.Result) {
+ if format == formatJSON {
+ jsonhttp.BadRequest(w, envelopeFor(res))
+ return
+ }
+
+ msg := res.Status.String()
+ if res.TrapMessage != "" {
+ msg += ": " + res.TrapMessage
+ }
+
+ if format == formatHTML {
+ w.Header().Set(ContentTypeHeader, "text/html; charset=utf-8")
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = fmt.Fprintf(w, "
execution failedexecution failed
%s
\n", html.EscapeString(msg))
+ return
+ }
+
+ w.Header().Set(ContentTypeHeader, "text/plain; charset=utf-8")
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = io.WriteString(w, msg+"\n")
+}
+
+const (
+ formatOctet = "octet"
+ formatJSON = "json"
+ formatHTML = "html"
+ // formatModule is raw output whose Content-Type comes entirely from the
+ // module. It is never negotiated directly: it is what a wildcard Accept
+ // becomes once the module has said something about its own response.
+ formatModule = "module"
+)
+
+// negotiateExecuteFormat picks a response representation from the Accept header.
+//
+// The second result reports whether the client named application/json outright,
+// as opposed to reaching JSON through a wildcard. That distinction matters
+// because a wildcard is not a request for the envelope, it is the absence of an
+// opinion — and a browser fetching a subresource sends one. A stylesheet request
+// is "Accept: text/css,*/*;q=0.1", an image request ends in "*/*;q=0.8", and a
+// default fetch() sends "*/*", so without this a module could never serve
+// anything but a top-level HTML page.
+//
+// The third result is false when the client requires a media type we cannot
+// produce.
+func negotiateExecuteFormat(accept string) (format string, explicit bool, ok bool) {
+ accept = strings.TrimSpace(accept)
+ if accept == "" {
+ return formatJSON, false, true
+ }
+ for _, part := range strings.Split(accept, ",") {
+ // Drop any parameters (e.g. q-values); we do not rank by quality.
+ mediaType := strings.TrimSpace(strings.SplitN(part, ";", 2)[0])
+ switch mediaType {
+ case "application/json":
+ return formatJSON, true, true
+ case "*/*", "application/*":
+ return formatJSON, false, true
+ case "text/html", "application/xhtml+xml":
+ return formatHTML, false, true
+ case "application/octet-stream":
+ return formatOctet, false, true
+ }
+ }
+ return "", false, false
+}
+
+// clampLimit resolves a per-request override against the configured default and
+// maximum. A nil override uses the default; any value is capped at the maximum.
+func clampLimit(override *uint64, def, maximum uint64) uint64 {
+ v := def
+ if override != nil {
+ v = *override
+ }
+ if maximum > 0 && v > maximum {
+ v = maximum
+ }
+ return v
+}
+
+var errTooLarge = errors.New("data exceeds maximum size")
+
+// readCapped reads all bytes from r, failing with errTooLarge if the content
+// exceeds maximum. A maximum of 0 means unlimited.
+func readCapped(r io.Reader, maximum uint64) ([]byte, error) {
+ if maximum == 0 {
+ return io.ReadAll(r)
+ }
+ buf, err := io.ReadAll(io.LimitReader(r, int64(maximum)+1))
+ if err != nil {
+ return nil, err
+ }
+ if uint64(len(buf)) > maximum {
+ return nil, errTooLarge
+ }
+ return buf, nil
+}
diff --git a/pkg/api/execute_env.go b/pkg/api/execute_env.go
new file mode 100644
index 00000000000..e13cf58fc18
--- /dev/null
+++ b/pkg/api/execute_env.go
@@ -0,0 +1,173 @@
+// Copyright 2026 The Swarm Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package api
+
+import (
+ "fmt"
+ "net/http"
+ "sort"
+ "strconv"
+ "strings"
+
+ "github.com/ethersphere/bee/v2/pkg/compute"
+)
+
+// The request metadata a module sees, CGI-style. Only the method used to be
+// exposed, which left a module with one URL and no way to tell one request from
+// another. The names and their meanings are CGI's, because that is the
+// convention REQUEST_METHOD already committed the ABI to.
+const (
+ envScriptName = "SCRIPT_NAME"
+ envPathInfo = "PATH_INFO"
+ envQueryString = "QUERY_STRING"
+ envRequestURI = "REQUEST_URI"
+ envContentType = "CONTENT_TYPE"
+ envContentLength = "CONTENT_LENGTH"
+ // envHeaderPrefix is CGI's prefix for a request header: Accept-Language
+ // becomes HTTP_ACCEPT_LANGUAGE.
+ envHeaderPrefix = "HTTP_"
+)
+
+// defaultRequestHeaders are the request headers a module may see when the
+// operator has not configured a list.
+//
+// This is an allowlist, deliberately unlike the response direction's denylist. A
+// response header carries only what the guest already knows; a request header
+// carries what the *operator's* clients send, so nothing gets through that is
+// not named here.
+//
+// Absent on purpose: Authorization, Cookie and Proxy-Authorization are node
+// credentials; Origin is the node's CORS business and would let a module
+// fingerprint the pages embedding it; Accept-Encoding belongs to the node, which
+// owns transfer encoding; X-Forwarded-For and Forwarded carry a visitor's IP,
+// which a module could persist to Swarm permanently.
+var defaultRequestHeaders = []string{
+ "Accept",
+ "Accept-Language",
+ "Host",
+ "If-None-Match",
+ "If-Modified-Since",
+ "Range",
+ "Referer",
+ "User-Agent",
+ "X-Requested-With",
+ // Client-supplied and already accepted by the CORS layer. A module cannot
+ // enumerate the node's batches, so this is how a caller hands it one to
+ // upload with.
+ "Swarm-Postage-Batch-Id",
+}
+
+// forbiddenRequestHeaders may never be forwarded, whatever an operator
+// configures. Authorization together with the Access-Control-Allow-Credentials
+// the node sets would mean handing an untrusted module the operator's token; an
+// operator with a real need puts a proxy in front and passes a derived header.
+var forbiddenRequestHeaders = map[string]struct{}{
+ "authorization": {},
+ "proxy-authorization": {},
+ "cookie": {},
+ "set-cookie": {},
+}
+
+// maxRequestHeaderValueLen is the longest header value forwarded to a module.
+// A longer one is dropped rather than truncated: a truncated value is a lie the
+// module cannot detect.
+const maxRequestHeaderValueLen = 4 << 10
+
+// ValidateRequestHeaders reports whether a configured request-header allowlist is
+// safe to serve. It is checked at startup so a dangerous configuration stops the
+// node rather than producing a warning nobody reads.
+func ValidateRequestHeaders(names []string) error {
+ for _, name := range names {
+ if _, forbidden := forbiddenRequestHeaders[strings.ToLower(strings.TrimSpace(name))]; forbidden {
+ return fmt.Errorf("request header %q may not be exposed to WASM modules", name)
+ }
+ }
+ return nil
+}
+
+// cgiHeaderName mangles a header name the way CGI does: upper case, dashes to
+// underscores, prefixed with HTTP_.
+func cgiHeaderName(name string) string {
+ return envHeaderPrefix + strings.ToUpper(strings.ReplaceAll(name, "-", "_"))
+}
+
+// executeEnv derives the CGI environment for one request.
+//
+// pathInfo is passed in rather than recomputed because only the router knows
+// whether the trailing-path route matched: on the bare route PATH_INFO is empty,
+// which is how a module distinguishes /@/{addr} from /@/{addr}/.
+func (s *Service) executeEnv(r *http.Request, pathInfo string) []compute.EnvVar {
+ env := []compute.EnvVar{
+ // SCRIPT_NAME is CGI's definition — the mount point, the request path
+ // minus PATH_INFO. Deriving it this way rather than rebuilding it from
+ // the address handles the /v1/@/... alias for free, and it is what a
+ // module needs to build links and redirects to itself.
+ {Name: envScriptName, Value: strings.TrimSuffix(r.URL.Path, pathInfo)},
+ {Name: envPathInfo, Value: pathInfo},
+ // Undecoded: a module that wants the decoded form decodes it, and one
+ // that needs the raw bytes still has them.
+ {Name: envQueryString, Value: r.URL.RawQuery},
+ {Name: envRequestURI, Value: r.URL.RequestURI()},
+ }
+
+ // Per CGI these two describe the body and are not repeated as HTTP_*.
+ if ct := r.Header.Get(ContentTypeHeader); ct != "" {
+ env = append(env, compute.EnvVar{Name: envContentType, Value: ct})
+ }
+ if r.ContentLength >= 0 {
+ env = append(env, compute.EnvVar{
+ Name: envContentLength,
+ Value: strconv.FormatInt(r.ContentLength, 10),
+ })
+ }
+
+ allowed := s.executeConfig.RequestHeaders
+ if len(allowed) == 0 {
+ allowed = defaultRequestHeaders
+ }
+
+ headers := make([]compute.EnvVar, 0, len(allowed))
+ for _, name := range allowed {
+ if _, forbidden := forbiddenRequestHeaders[strings.ToLower(name)]; forbidden {
+ continue
+ }
+ values := r.Header.Values(name)
+ if len(values) == 0 {
+ continue
+ }
+ // Repeats join the way a single field-value would have been written.
+ value := strings.Join(values, ", ")
+ if len(value) > maxRequestHeaderValueLen {
+ continue
+ }
+ headers = append(headers, compute.EnvVar{Name: cgiHeaderName(name), Value: value})
+ }
+ sort.Slice(headers, func(i, j int) bool { return headers[i].Name < headers[j].Name })
+
+ return append(env, sanitiseEnv(headers)...)
+}
+
+// sanitiseEnv drops entries whose value carries a control character. Such a byte
+// has no meaning in an environment block and a NUL would truncate it outright.
+func sanitiseEnv(env []compute.EnvVar) []compute.EnvVar {
+ out := env[:0]
+ for _, v := range env {
+ if strings.ContainsFunc(v.Value, func(r rune) bool { return r < 0x20 || r == 0x7f }) {
+ continue
+ }
+ out = append(out, v)
+ }
+ return out
+}
+
+// envSize is the number of bytes an environment block occupies, counting the
+// "name=value\x00" framing WASI uses.
+func envSize(env []compute.EnvVar) int {
+ total := 0
+ for _, v := range env {
+ total += len(v.Name) + len(v.Value) + 2
+ }
+ return total
+}
diff --git a/pkg/api/execute_env_test.go b/pkg/api/execute_env_test.go
new file mode 100644
index 00000000000..706365da898
--- /dev/null
+++ b/pkg/api/execute_env_test.go
@@ -0,0 +1,182 @@
+// Copyright 2026 The Swarm Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package api_test
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/ethersphere/bee/v2/pkg/api"
+ "github.com/ethersphere/bee/v2/pkg/compute"
+ "github.com/ethersphere/bee/v2/pkg/jsonhttp/jsonhttptest"
+)
+
+// envOf runs a request and returns the environment the engine was handed.
+func envOf(t *testing.T, engine *mockEngine) map[string]string {
+ t.Helper()
+
+ env := make(map[string]string, len(engine.request.Env))
+ for _, v := range engine.request.Env {
+ env[v.Name] = v.Value
+ }
+ return env
+}
+
+func TestExecutePathInfo(t *testing.T) {
+ t.Parallel()
+
+ for _, tc := range []struct {
+ name string
+ suffix string
+ wantPathInfo string
+ wantScriptTail string
+ }{
+ // CGI's rule: the bare form has no PATH_INFO at all, which is how a
+ // module tells /@/{addr} from /@/{addr}/ and can redirect between them.
+ {"bare address", "", "", ""},
+ {"trailing slash", "/", "/", ""},
+ {"single segment", "/style.css", "/style.css", ""},
+ {"nested path", "/blob/abc/def", "/blob/abc/def", ""},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ engine := &mockEngine{result: compute.Result{Status: compute.StatusOK}}
+ client := newExecuteTestServer(t, engine, api.ExecuteConfig{})
+ addr := uploadModule(t, client, []byte("module"))
+
+ var body []byte
+ jsonhttptest.Request(t, client, http.MethodGet, "/@/"+addr.String()+tc.suffix,
+ http.StatusOK, jsonhttptest.WithPutResponseBody(&body))
+
+ env := envOf(t, engine)
+ if got := env["PATH_INFO"]; got != tc.wantPathInfo {
+ t.Errorf("PATH_INFO: got %q, want %q", got, tc.wantPathInfo)
+ }
+ // SCRIPT_NAME is the mount point: the request path minus PATH_INFO.
+ wantScript := "/@/" + addr.String() + tc.wantScriptTail
+ if got := env["SCRIPT_NAME"]; got != wantScript {
+ t.Errorf("SCRIPT_NAME: got %q, want %q", got, wantScript)
+ }
+ // Concatenating them must reproduce the path the client asked for,
+ // which is the property a module relies on to build self-links.
+ if got := env["SCRIPT_NAME"] + env["PATH_INFO"]; got != "/@/"+addr.String()+tc.suffix {
+ t.Errorf("SCRIPT_NAME+PATH_INFO: got %q, want %q", got, "/@/"+addr.String()+tc.suffix)
+ }
+ })
+ }
+}
+
+func TestExecuteRequestEnv(t *testing.T) {
+ t.Parallel()
+
+ engine := &mockEngine{result: compute.Result{Status: compute.StatusOK}}
+ client := newExecuteTestServer(t, engine, api.ExecuteConfig{})
+ addr := uploadModule(t, client, []byte("module"))
+
+ var body []byte
+ jsonhttptest.Request(t, client, http.MethodPost,
+ "/@/"+addr.String()+"/upload?name=photo.png&size=3", http.StatusOK,
+ jsonhttptest.WithRequestHeader(api.ContentTypeHeader, "application/octet-stream"),
+ jsonhttptest.WithRequestHeader(api.AcceptHeader, "application/json"),
+ jsonhttptest.WithRequestHeader("User-Agent", "test-agent"),
+ jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr),
+ jsonhttptest.WithRequestBody(strings.NewReader("abc")),
+ jsonhttptest.WithPutResponseBody(&body),
+ )
+
+ env := envOf(t, engine)
+ for _, tc := range []struct{ name, want string }{
+ {"QUERY_STRING", "name=photo.png&size=3"},
+ {"REQUEST_URI", "/@/" + addr.String() + "/upload?name=photo.png&size=3"},
+ {"CONTENT_TYPE", "application/octet-stream"},
+ {"CONTENT_LENGTH", "3"},
+ {"HTTP_USER_AGENT", "test-agent"},
+ {"HTTP_ACCEPT", "application/json"},
+ // The batch id is allowlisted on purpose: a module cannot enumerate the
+ // node's batches, so this is how a caller hands it one.
+ {"HTTP_SWARM_POSTAGE_BATCH_ID", batchOkStr},
+ } {
+ if got := env[tc.name]; got != tc.want {
+ t.Errorf("%s: got %q, want %q", tc.name, got, tc.want)
+ }
+ }
+}
+
+// Forwarding request headers must never hand a module the operator's
+// credentials, whatever the configuration says.
+func TestExecuteRequestHeaderAllowlist(t *testing.T) {
+ t.Parallel()
+
+ for _, tc := range []struct {
+ name string
+ configure []string
+ }{
+ {"default list", nil},
+ {"operator tries to add them", []string{"Accept", "Authorization", "Cookie"}},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ engine := &mockEngine{result: compute.Result{Status: compute.StatusOK}}
+ client := newExecuteTestServer(t, engine, api.ExecuteConfig{
+ RequestHeaders: tc.configure,
+ })
+ addr := uploadModule(t, client, []byte("module"))
+
+ var body []byte
+ jsonhttptest.Request(t, client, http.MethodGet, "/@/"+addr.String()+"/", http.StatusOK,
+ jsonhttptest.WithRequestHeader("Authorization", "Bearer operator-token"),
+ jsonhttptest.WithRequestHeader("Cookie", "session=secret"),
+ jsonhttptest.WithRequestHeader(api.AcceptHeader, "text/html"),
+ jsonhttptest.WithPutResponseBody(&body),
+ )
+
+ env := envOf(t, engine)
+ for _, name := range []string{"HTTP_AUTHORIZATION", "HTTP_COOKIE"} {
+ if got, ok := env[name]; ok {
+ t.Errorf("%s leaked to the guest: %q", name, got)
+ }
+ }
+ if got := env["HTTP_ACCEPT"]; got != "text/html" {
+ t.Errorf("HTTP_ACCEPT: got %q, want text/html", got)
+ }
+ })
+ }
+}
+
+// A configuration naming a credential header is a startup error, not a warning.
+func TestValidateRequestHeaders(t *testing.T) {
+ t.Parallel()
+
+ if err := api.ValidateRequestHeaders([]string{"Accept", "User-Agent"}); err != nil {
+ t.Errorf("a sane list was rejected: %v", err)
+ }
+ for _, name := range []string{"Authorization", "authorization", " Cookie ", "Proxy-Authorization"} {
+ if err := api.ValidateRequestHeaders([]string{"Accept", name}); err == nil {
+ t.Errorf("%q was accepted", name)
+ }
+ }
+}
+
+// An oversized environment is refused outright rather than truncated: a
+// truncated environment is a lie the module cannot detect.
+func TestExecuteEnvTooLarge(t *testing.T) {
+ t.Parallel()
+
+ engine := &mockEngine{result: compute.Result{Status: compute.StatusOK}}
+ client := newExecuteTestServer(t, engine, api.ExecuteConfig{MaxEnvBytes: 128})
+ addr := uploadModule(t, client, []byte("module"))
+
+ var body []byte
+ jsonhttptest.Request(t, client, http.MethodGet,
+ "/@/"+addr.String()+"/"+strings.Repeat("a", 512), http.StatusRequestHeaderFieldsTooLarge,
+ jsonhttptest.WithPutResponseBody(&body))
+
+ if engine.calls != 0 {
+ t.Errorf("engine ran despite an oversized environment")
+ }
+}
diff --git a/pkg/api/execute_integration_test.go b/pkg/api/execute_integration_test.go
new file mode 100644
index 00000000000..767e664f934
--- /dev/null
+++ b/pkg/api/execute_integration_test.go
@@ -0,0 +1,127 @@
+// Copyright 2026 The Swarm Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package api_test
+
+import (
+ "bytes"
+ "net/http"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/ethersphere/bee/v2/pkg/api"
+ "github.com/ethersphere/bee/v2/pkg/compute"
+ "github.com/ethersphere/bee/v2/pkg/jsonhttp/jsonhttptest"
+ "github.com/ethersphere/bee/v2/pkg/log"
+)
+
+// realEngineClient wires the actual wazero engine into the API test server, so
+// this exercises the whole chain a request travels: HTTP handler -> negotiation
+// -> engine -> swarm host module -> render. The mockEngine tests above pin the
+// HTTP layer's behaviour; this pins that the layers agree.
+func realEngineClient(t *testing.T, cfg api.ExecuteConfig) *http.Client {
+ t.Helper()
+
+ engine, err := compute.New(compute.Options{
+ Workers: 1,
+ Watchdog: 10 * time.Second,
+ Logger: log.Noop,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ if err := engine.Close(); err != nil {
+ t.Errorf("close compute service: %v", err)
+ }
+ })
+ return newExecuteTestServer(t, engine, cfg)
+}
+
+// computeFixture loads a hand-written module from the compute package's
+// testdata, which is where the fixtures and their .wat sources live.
+func computeFixture(t *testing.T, name string) []byte {
+ t.Helper()
+
+ module, err := os.ReadFile(filepath.Join("..", "compute", "testdata", name+".wasm"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return module
+}
+
+// A real module setting a real Content-Type must reach a browser as that type,
+// through the real negotiation path. This is the end-to-end form of the bug that
+// motivated the format upgrade: before it, a stylesheet request came back as a
+// base64 JSON envelope.
+func TestExecuteEndToEndGuestHeaders(t *testing.T) {
+ t.Parallel()
+
+ client := realEngineClient(t, api.ExecuteConfig{})
+ addr := uploadModule(t, client, computeFixture(t, "respok"))
+
+ jsonhttptest.Request(t, client, http.MethodGet, "/@/"+addr.String()+"/style.css",
+ http.StatusCreated, // the module set 201
+ jsonhttptest.WithRequestHeader(api.AcceptHeader, acceptStylesheet),
+ jsonhttptest.WithExpectedResponse([]byte("hi")),
+ jsonhttptest.WithExpectedResponseHeader(api.ContentTypeHeader, "text/css"),
+ jsonhttptest.WithExpectedResponseHeader("Cache-Control", "max-age=60"),
+ jsonhttptest.WithExpectedResponseHeader(api.SwarmWasmStatusHeader, "ok"),
+ )
+}
+
+// The same module through an explicit application/json must report rather than
+// apply, and answer 200 because the envelope itself was delivered.
+func TestExecuteEndToEndEnvelopeReports(t *testing.T) {
+ t.Parallel()
+
+ client := realEngineClient(t, api.ExecuteConfig{})
+ addr := uploadModule(t, client, computeFixture(t, "respok"))
+
+ var body []byte
+ jsonhttptest.Request(t, client, http.MethodGet, "/@/"+addr.String()+"/", http.StatusOK,
+ jsonhttptest.WithRequestHeader(api.AcceptHeader, "application/json"),
+ jsonhttptest.WithPutResponseBody(&body),
+ )
+ for _, want := range []string{`"httpStatus":201`, `"Content-Type"`, `"text/css"`} {
+ if !bytes.Contains(body, []byte(want)) {
+ t.Errorf("envelope %s does not contain %s", body, want)
+ }
+ }
+}
+
+// A real trapping module must not leave its headers or status behind.
+func TestExecuteEndToEndTrapDropsMetadata(t *testing.T) {
+ t.Parallel()
+
+ client := realEngineClient(t, api.ExecuteConfig{})
+ addr := uploadModule(t, client, computeFixture(t, "resptrap"))
+
+ var body []byte
+ jsonhttptest.Request(t, client, http.MethodGet, "/@/"+addr.String()+"/",
+ // 400 from the verdict, not the 418 the module asked for.
+ http.StatusBadRequest,
+ jsonhttptest.WithRequestHeader(api.AcceptHeader, acceptNavigation),
+ jsonhttptest.WithExpectedResponseHeader(api.ContentTypeHeader, "text/html; charset=utf-8"),
+ jsonhttptest.WithExpectedResponseHeader(api.SwarmWasmStatusHeader, "trap"),
+ jsonhttptest.WithPutResponseBody(&body),
+ )
+}
+
+// A module that shapes its response needs no node access, while one that reaches
+// for the node's data on such a node is still invalid.
+func TestExecuteEndToEndResponseWithoutNodeAccess(t *testing.T) {
+ t.Parallel()
+
+ client := realEngineClient(t, api.ExecuteConfig{})
+ addr := uploadModule(t, client, computeFixture(t, "respok"))
+
+ jsonhttptest.Request(t, client, http.MethodGet, "/@/"+addr.String()+"/", http.StatusCreated,
+ jsonhttptest.WithRequestHeader(api.AcceptHeader, acceptFetch),
+ jsonhttptest.WithExpectedResponse([]byte("hi")),
+ jsonhttptest.WithExpectedResponseHeader(api.ContentTypeHeader, "text/css"),
+ )
+}
diff --git a/pkg/api/execute_response_test.go b/pkg/api/execute_response_test.go
new file mode 100644
index 00000000000..ede55b3901a
--- /dev/null
+++ b/pkg/api/execute_response_test.go
@@ -0,0 +1,276 @@
+// Copyright 2026 The Swarm Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package api_test
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "testing"
+
+ "github.com/ethersphere/bee/v2/pkg/api"
+ "github.com/ethersphere/bee/v2/pkg/compute"
+ "github.com/ethersphere/bee/v2/pkg/jsonhttp/jsonhttptest"
+)
+
+// Real Accept headers browsers send. A subresource request never names
+// text/html, so before the format upgrade every one of these came back as a
+// base64 JSON envelope and no module could serve a stylesheet or an image.
+const (
+ acceptStylesheet = "text/css,*/*;q=0.1"
+ acceptImage = "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8"
+ acceptFetch = "*/*"
+ acceptNavigation = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8"
+)
+
+// okWith builds a clean result carrying the guest's response metadata.
+func okWith(output string, status int, headers ...compute.Header) compute.Result {
+ return compute.Result{
+ Status: compute.StatusOK,
+ Output: []byte(output),
+ Response: compute.ResponseMeta{Status: status, Headers: headers},
+ }
+}
+
+func TestExecuteGuestContentType(t *testing.T) {
+ t.Parallel()
+
+ css := "body{color:red}"
+
+ for _, tc := range []struct {
+ name string
+ accept string
+ wantType string
+ }{
+ {"browser stylesheet request", acceptStylesheet, "text/css"},
+ {"browser image request", acceptImage, "text/css"},
+ {"default fetch", acceptFetch, "text/css"},
+ {"no accept header at all", "", "text/css"},
+ {"top-level navigation", acceptNavigation, "text/css"},
+ {"explicit octet-stream", "application/octet-stream", "text/css"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ engine := &mockEngine{result: okWith(css, 0,
+ compute.Header{Name: "Content-Type", Value: "text/css"},
+ compute.Header{Name: "Cache-Control", Value: "max-age=60"},
+ )}
+ client := newExecuteTestServer(t, engine, api.ExecuteConfig{})
+ addr := uploadModule(t, client, []byte("module"))
+
+ opts := []jsonhttptest.Option{
+ jsonhttptest.WithExpectedResponse([]byte(css)),
+ jsonhttptest.WithExpectedResponseHeader(api.ContentTypeHeader, tc.wantType),
+ jsonhttptest.WithExpectedResponseHeader("Cache-Control", "max-age=60"),
+ jsonhttptest.WithExpectedResponseHeader(api.SwarmWasmStatusHeader, "ok"),
+ }
+ if tc.accept != "" {
+ opts = append(opts, jsonhttptest.WithRequestHeader(api.AcceptHeader, tc.accept))
+ }
+
+ jsonhttptest.Request(t, client, http.MethodGet, "/@/"+addr.String(), http.StatusOK, opts...)
+ })
+ }
+}
+
+// A module that sets nothing must behave exactly as it did before the response
+// functions existed. This is the regression that keeps the change additive.
+func TestExecuteWildcardWithoutMetadataStillEnvelope(t *testing.T) {
+ t.Parallel()
+
+ output := []byte("plain result")
+
+ for _, accept := range []string{"", acceptFetch, "application/*"} {
+ engine := &mockEngine{result: compute.Result{Status: compute.StatusOK, Output: output}}
+ client := newExecuteTestServer(t, engine, api.ExecuteConfig{})
+ addr := uploadModule(t, client, []byte("module"))
+
+ opts := []jsonhttptest.Option{}
+ var body []byte
+ opts = append(opts, jsonhttptest.WithPutResponseBody(&body))
+ if accept != "" {
+ opts = append(opts, jsonhttptest.WithRequestHeader(api.AcceptHeader, accept))
+ }
+
+ jsonhttptest.Request(t, client, http.MethodPost, "/@/"+addr.String(), http.StatusOK, opts...)
+
+ var resp struct {
+ Status string `json:"status"`
+ Output []byte `json:"output"`
+ }
+ if err := json.Unmarshal(body, &resp); err != nil {
+ t.Fatalf("accept %q: unmarshal %q: %v", accept, body, err)
+ }
+ if resp.Status != "ok" || !bytes.Equal(resp.Output, output) {
+ t.Errorf("accept %q: got %+v, want the unchanged envelope", accept, resp)
+ }
+ }
+}
+
+// An explicit application/json is a request to be told about the run, so the
+// guest's metadata is reported as fields and never applied to the transport.
+func TestExecuteExplicitJSONReportsMetadata(t *testing.T) {
+ t.Parallel()
+
+ engine := &mockEngine{result: okWith("hi
", 404,
+ compute.Header{Name: "Content-Type", Value: "text/html"},
+ compute.Header{Name: "Link", Value: "; rel=next"},
+ compute.Header{Name: "Link", Value: "; rel=prev"},
+ )}
+ client := newExecuteTestServer(t, engine, api.ExecuteConfig{})
+ addr := uploadModule(t, client, []byte("module"))
+
+ var body []byte
+ jsonhttptest.Request(t, client, http.MethodGet, "/@/"+addr.String(), http.StatusOK,
+ jsonhttptest.WithRequestHeader(api.AcceptHeader, "application/json"),
+ // The envelope is JSON regardless of what the module asked for.
+ jsonhttptest.WithExpectedResponseHeader(api.ContentTypeHeader, "application/json; charset=utf-8"),
+ jsonhttptest.WithPutResponseBody(&body),
+ )
+
+ var resp struct {
+ Status string `json:"status"`
+ HTTPStatus int `json:"httpStatus"`
+ Headers map[string][]string `json:"headers"`
+ }
+ if err := json.Unmarshal(body, &resp); err != nil {
+ t.Fatalf("unmarshal %q: %v", body, err)
+ }
+ if resp.Status != "ok" {
+ t.Errorf("status: got %q, want ok", resp.Status)
+ }
+ if resp.HTTPStatus != 404 {
+ t.Errorf("httpStatus: got %d, want 404", resp.HTTPStatus)
+ }
+ if got := resp.Headers["Link"]; len(got) != 2 {
+ t.Errorf("Link: got %v, want both values in order", got)
+ }
+ if got := resp.Headers["Content-Type"]; len(got) != 1 || got[0] != "text/html" {
+ t.Errorf("Content-Type: got %v", got)
+ }
+}
+
+func TestExecuteGuestStatusCode(t *testing.T) {
+ t.Parallel()
+
+ for _, tc := range []struct {
+ name string
+ status int
+ wantHTTP int
+ }{
+ {"module reports not found", 404, http.StatusNotFound},
+ {"module redirects", 303, http.StatusSeeOther},
+ {"module reports its own failure", 500, http.StatusInternalServerError},
+ {"unset falls back to 200", 0, http.StatusOK},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ engine := &mockEngine{result: okWith("body", tc.status,
+ compute.Header{Name: "Content-Type", Value: "text/plain"},
+ )}
+ client := newExecuteTestServer(t, engine, api.ExecuteConfig{})
+ addr := uploadModule(t, client, []byte("module"))
+
+ jsonhttptest.Request(t, client, http.MethodGet, "/@/"+addr.String(), tc.wantHTTP,
+ jsonhttptest.WithRequestHeader(api.AcceptHeader, acceptFetch),
+ jsonhttptest.WithExpectedResponse([]byte("body")),
+ // The verdict header stays truthful even when the module reports
+ // a 500 of its own: ok here, host-error if the node had failed.
+ jsonhttptest.WithExpectedResponseHeader(api.SwarmWasmStatusHeader, "ok"),
+ )
+ })
+ }
+}
+
+// The engine enforces the denylist; the API re-checks it so an engine bug cannot
+// become a header leak. Asserted on the raw response, because what matters here
+// is that the headers are absent.
+func TestExecuteDeniedHeadersFilteredAtAPI(t *testing.T) {
+ t.Parallel()
+
+ engine := &mockEngine{result: okWith("body", 0,
+ compute.Header{Name: "Content-Type", Value: "text/plain"},
+ compute.Header{Name: "Access-Control-Allow-Origin", Value: "*"},
+ compute.Header{Name: "Set-Cookie", Value: "sid=1"},
+ compute.Header{Name: "Strict-Transport-Security", Value: "max-age=31536000"},
+ compute.Header{Name: "Transfer-Encoding", Value: "chunked"},
+ compute.Header{Name: api.SwarmWasmStatusHeader, Value: "trap"},
+ )}
+ client := newExecuteTestServer(t, engine, api.ExecuteConfig{})
+ addr := uploadModule(t, client, []byte("module"))
+
+ req, err := http.NewRequest(http.MethodGet, "/@/"+addr.String(), nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Header.Set(api.AcceptHeader, acceptFetch)
+
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+
+ for _, name := range []string{
+ "Access-Control-Allow-Origin",
+ "Set-Cookie",
+ "Strict-Transport-Security",
+ "Transfer-Encoding",
+ } {
+ if got := resp.Header.Values(name); len(got) != 0 {
+ t.Errorf("%s leaked through: %v", name, got)
+ }
+ }
+ // The one header it was allowed to set still applies, so the filter is not
+ // simply dropping everything.
+ if got := resp.Header.Get(api.ContentTypeHeader); got != "text/plain" {
+ t.Errorf("content-type: got %q, want text/plain", got)
+ }
+ // And the verdict survived the guest's attempt to forge it.
+ if got := resp.Header.Get(api.SwarmWasmStatusHeader); got != "ok" {
+ t.Errorf("%s: got %q, want ok", api.SwarmWasmStatusHeader, got)
+ }
+}
+
+// A client that negotiated HTML must not receive a JSON body when the module
+// trapped: answering with the wrong content type is a lie regardless of status.
+func TestExecuteTrapBodyMatchesNegotiatedType(t *testing.T) {
+ t.Parallel()
+
+ for _, tc := range []struct {
+ name string
+ accept string
+ wantType string
+ }{
+ {"html", acceptNavigation, "text/html; charset=utf-8"},
+ {"octet-stream", "application/octet-stream", "text/plain; charset=utf-8"},
+ {"json", "application/json", "application/json; charset=utf-8"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ engine := &mockEngine{result: compute.Result{
+ Status: compute.StatusTrap,
+ Output: []byte("partial"),
+ TrapMessage: "unreachable",
+ }}
+ client := newExecuteTestServer(t, engine, api.ExecuteConfig{})
+ addr := uploadModule(t, client, []byte("module"))
+
+ var body []byte
+ jsonhttptest.Request(t, client, http.MethodGet, "/@/"+addr.String(), http.StatusBadRequest,
+ jsonhttptest.WithRequestHeader(api.AcceptHeader, tc.accept),
+ jsonhttptest.WithExpectedResponseHeader(api.ContentTypeHeader, tc.wantType),
+ jsonhttptest.WithExpectedResponseHeader(api.SwarmWasmStatusHeader, "trap"),
+ jsonhttptest.WithPutResponseBody(&body),
+ )
+ if !bytes.Contains(body, []byte("unreachable")) {
+ t.Errorf("body %q does not name the trap", body)
+ }
+ })
+ }
+}
diff --git a/pkg/api/execute_test.go b/pkg/api/execute_test.go
new file mode 100644
index 00000000000..380f0a3e98b
--- /dev/null
+++ b/pkg/api/execute_test.go
@@ -0,0 +1,547 @@
+// Copyright 2026 The Swarm Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package api_test
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "testing"
+
+ "github.com/ethersphere/bee/v2/pkg/api"
+ "github.com/ethersphere/bee/v2/pkg/compute"
+ "github.com/ethersphere/bee/v2/pkg/jsonhttp"
+ "github.com/ethersphere/bee/v2/pkg/jsonhttp/jsonhttptest"
+ "github.com/ethersphere/bee/v2/pkg/log"
+ mockpost "github.com/ethersphere/bee/v2/pkg/postage/mock"
+ mockstorer "github.com/ethersphere/bee/v2/pkg/storer/mock"
+ "github.com/ethersphere/bee/v2/pkg/swarm"
+)
+
+// mockEngine is a compute.Engine that records what it was asked to run and
+// replays a canned outcome.
+type mockEngine struct {
+ result compute.Result
+ err error
+ request compute.Request
+ calls int
+}
+
+func (m *mockEngine) Execute(_ context.Context, req compute.Request) (compute.Result, error) {
+ m.calls++
+ m.request = req
+ return m.result, m.err
+}
+
+func (m *mockEngine) Close() error { return nil }
+
+// uploadModule stores content through the bytes endpoint and returns its address.
+func uploadModule(t *testing.T, client *http.Client, content []byte) swarm.Address {
+ t.Helper()
+
+ var resp api.BytesPostResponse
+ jsonhttptest.Request(t, client, http.MethodPost, "/bytes", http.StatusCreated,
+ jsonhttptest.WithRequestHeader(api.SwarmDeferredUploadHeader, "true"),
+ jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr),
+ jsonhttptest.WithRequestBody(bytes.NewReader(content)),
+ jsonhttptest.WithUnmarshalJSONResponse(&resp),
+ )
+ return resp.Reference
+}
+
+func newExecuteTestServer(t *testing.T, engine compute.Engine, cfg api.ExecuteConfig) *http.Client {
+ t.Helper()
+
+ client, _, _, _ := newTestServer(t, testServerOptions{
+ Storer: mockstorer.New(),
+ Logger: log.Noop,
+ Post: mockpost.New(mockpost.WithAcceptAll()),
+ Compute: engine,
+ ExecuteConfig: cfg,
+ })
+ return client
+}
+
+// TestExecute checks that the module is downloaded from Swarm, the request body
+// is handed to the engine as input and the result comes back as raw bytes.
+func TestExecute(t *testing.T) {
+ t.Parallel()
+
+ module := []byte("this stands in for a wasm module")
+ engine := &mockEngine{result: compute.Result{
+ Status: compute.StatusOK,
+ Output: []byte("computed output"),
+ }}
+
+ client := newExecuteTestServer(t, engine, api.ExecuteConfig{})
+ addr := uploadModule(t, client, module)
+
+ jsonhttptest.Request(t, client, http.MethodPost, "/@/"+addr.String(), http.StatusOK,
+ jsonhttptest.WithRequestHeader(api.AcceptHeader, "application/octet-stream"),
+ jsonhttptest.WithRequestBody(bytes.NewReader([]byte("the input"))),
+ jsonhttptest.WithExpectedResponse([]byte("computed output")),
+ jsonhttptest.WithExpectedResponseHeader(api.SwarmWasmStatusHeader, "ok"),
+ )
+
+ if engine.calls != 1 {
+ t.Fatalf("got %d engine calls, want 1", engine.calls)
+ }
+ if !bytes.Equal(engine.request.Module, module) {
+ t.Errorf("got module %q, want %q", engine.request.Module, module)
+ }
+ if string(engine.request.Input) != "the input" {
+ t.Errorf("got input %q, want %q", engine.request.Input, "the input")
+ }
+}
+
+func TestExecuteDisabled(t *testing.T) {
+ t.Parallel()
+
+ client := newExecuteTestServer(t, nil, api.ExecuteConfig{})
+
+ jsonhttptest.Request(t, client, http.MethodPost, "/@/"+swarm.RandAddress(t).String(), http.StatusForbidden,
+ jsonhttptest.WithExpectedJSONResponse(jsonhttp.StatusResponse{
+ Message: "WASM execution is disabled. This endpoint is unavailable.",
+ Code: http.StatusForbidden,
+ }),
+ )
+}
+
+func TestExecuteModuleNotFound(t *testing.T) {
+ t.Parallel()
+
+ engine := &mockEngine{}
+ client := newExecuteTestServer(t, engine, api.ExecuteConfig{})
+
+ jsonhttptest.Request(t, client, http.MethodPost, "/@/"+swarm.RandAddress(t).String(), http.StatusNotFound,
+ jsonhttptest.WithExpectedJSONResponse(jsonhttp.StatusResponse{
+ Message: "module not found",
+ Code: http.StatusNotFound,
+ }),
+ )
+
+ if engine.calls != 0 {
+ t.Errorf("got %d engine calls, want none for a missing module", engine.calls)
+ }
+}
+
+func TestExecuteModuleTooLarge(t *testing.T) {
+ t.Parallel()
+
+ engine := &mockEngine{}
+ client := newExecuteTestServer(t, engine, api.ExecuteConfig{MaxModuleSize: 8})
+ addr := uploadModule(t, client, []byte("well over eight bytes"))
+
+ jsonhttptest.Request(t, client, http.MethodPost, "/@/"+addr.String(), http.StatusRequestEntityTooLarge,
+ jsonhttptest.WithExpectedJSONResponse(jsonhttp.StatusResponse{
+ Message: "module exceeds maximum size",
+ Code: http.StatusRequestEntityTooLarge,
+ }),
+ )
+
+ if engine.calls != 0 {
+ t.Errorf("got %d engine calls, want none for an oversized module", engine.calls)
+ }
+}
+
+func TestExecuteBusy(t *testing.T) {
+ t.Parallel()
+
+ engine := &mockEngine{err: compute.ErrBusy}
+ client := newExecuteTestServer(t, engine, api.ExecuteConfig{})
+ addr := uploadModule(t, client, []byte("module"))
+
+ jsonhttptest.Request(t, client, http.MethodPost, "/@/"+addr.String(), http.StatusTooManyRequests,
+ jsonhttptest.WithExpectedJSONResponse(jsonhttp.StatusResponse{
+ Message: "execution workers busy",
+ Code: http.StatusTooManyRequests,
+ }),
+ )
+}
+
+func TestExecuteEngineFailure(t *testing.T) {
+ t.Parallel()
+
+ engine := &mockEngine{err: errors.New("engine exploded")}
+ client := newExecuteTestServer(t, engine, api.ExecuteConfig{})
+ addr := uploadModule(t, client, []byte("module"))
+
+ jsonhttptest.Request(t, client, http.MethodPost, "/@/"+addr.String(), http.StatusInternalServerError,
+ jsonhttptest.WithExpectedJSONResponse(jsonhttp.StatusResponse{
+ Message: "execution failed",
+ Code: http.StatusInternalServerError,
+ }),
+ )
+}
+
+// TestExecuteStatusMapping checks that a program verdict maps to a stable HTTP
+// status: a bad module or a trap is the caller's fault, a host error is ours.
+func TestExecuteStatusMapping(t *testing.T) {
+ t.Parallel()
+
+ for _, tc := range []struct {
+ name string
+ result compute.Result
+ wantHTTP int
+ wantStatus string
+ }{
+ {
+ name: "ok",
+ result: compute.Result{Status: compute.StatusOK, Output: []byte("out")},
+ wantHTTP: http.StatusOK,
+ wantStatus: "ok",
+ },
+ {
+ name: "trap",
+ result: compute.Result{Status: compute.StatusTrap, TrapMessage: "unreachable"},
+ wantHTTP: http.StatusBadRequest,
+ wantStatus: "trap",
+ },
+ {
+ name: "invalid module",
+ result: compute.Result{Status: compute.StatusInvalidModule, TrapMessage: "invalid magic number"},
+ wantHTTP: http.StatusBadRequest,
+ wantStatus: "invalid-module",
+ },
+ {
+ name: "host error",
+ result: compute.Result{Status: compute.StatusHostError},
+ wantHTTP: http.StatusInternalServerError,
+ wantStatus: "host-error",
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ engine := &mockEngine{result: tc.result}
+ client := newExecuteTestServer(t, engine, api.ExecuteConfig{})
+ addr := uploadModule(t, client, []byte("module"))
+
+ jsonhttptest.Request(t, client, http.MethodPost, "/@/"+addr.String(), tc.wantHTTP,
+ jsonhttptest.WithExpectedResponseHeader(api.SwarmWasmStatusHeader, tc.wantStatus),
+ )
+ })
+ }
+}
+
+// TestExecuteContentNegotiation checks the representation chosen for each Accept
+// header, and that an unsupported one is rejected before the module is run.
+func TestExecuteContentNegotiation(t *testing.T) {
+ t.Parallel()
+
+ output := []byte("result")
+
+ for _, tc := range []struct {
+ name string
+ accept string
+ wantHTTP int
+ wantType string
+ wantBody []byte
+ wantJSON bool
+ wantExecute bool
+ }{
+ {
+ name: "no accept header defaults to json",
+ wantHTTP: http.StatusOK,
+ wantJSON: true,
+ wantExecute: true,
+ },
+ {
+ name: "octet-stream",
+ accept: "application/octet-stream",
+ wantHTTP: http.StatusOK,
+ wantType: "application/octet-stream",
+ wantBody: output,
+ wantExecute: true,
+ },
+ {
+ name: "wildcard defaults to json",
+ accept: "*/*",
+ wantHTTP: http.StatusOK,
+ wantJSON: true,
+ wantExecute: true,
+ },
+ {
+ name: "html",
+ accept: "text/html",
+ wantHTTP: http.StatusOK,
+ wantType: "text/html; charset=utf-8",
+ wantBody: output,
+ wantExecute: true,
+ },
+ {
+ name: "json",
+ accept: "application/json",
+ wantHTTP: http.StatusOK,
+ wantJSON: true,
+ wantExecute: true,
+ },
+ {
+ name: "first supported type wins",
+ accept: "image/png, application/json;q=0.9",
+ wantHTTP: http.StatusOK,
+ wantJSON: true,
+ wantExecute: true,
+ },
+ {
+ name: "unsupported type",
+ accept: "image/png",
+ wantHTTP: http.StatusNotAcceptable,
+ wantJSON: false,
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ engine := &mockEngine{result: compute.Result{
+ Status: compute.StatusOK,
+ Output: output,
+ }}
+ client := newExecuteTestServer(t, engine, api.ExecuteConfig{})
+ addr := uploadModule(t, client, []byte("module"))
+
+ opts := []jsonhttptest.Option{}
+ if tc.accept != "" {
+ opts = append(opts, jsonhttptest.WithRequestHeader(api.AcceptHeader, tc.accept))
+ }
+
+ var body []byte
+ if tc.wantJSON || tc.wantHTTP == http.StatusNotAcceptable {
+ opts = append(opts, jsonhttptest.WithPutResponseBody(&body))
+ } else {
+ opts = append(opts,
+ jsonhttptest.WithExpectedResponse(tc.wantBody),
+ jsonhttptest.WithExpectedResponseHeader(api.ContentTypeHeader, tc.wantType),
+ )
+ }
+
+ jsonhttptest.Request(t, client, http.MethodPost, "/@/"+addr.String(), tc.wantHTTP, opts...)
+
+ if tc.wantJSON {
+ var resp struct {
+ Status string `json:"status"`
+ Output []byte `json:"output"`
+ }
+ if err := json.Unmarshal(body, &resp); err != nil {
+ t.Fatalf("unmarshal response %q: %v", body, err)
+ }
+ if resp.Status != "ok" {
+ t.Errorf("got status %q, want %q", resp.Status, "ok")
+ }
+ if !bytes.Equal(resp.Output, output) {
+ t.Errorf("got output %q, want %q", resp.Output, output)
+ }
+ }
+
+ if got := engine.calls > 0; got != tc.wantExecute {
+ t.Errorf("engine called: %v, want %v", got, tc.wantExecute)
+ }
+ })
+ }
+}
+
+// TestExecuteLimits checks that per-request limits are clamped to the operator
+// configured maxima and fall back to the configured defaults.
+func TestExecuteLimits(t *testing.T) {
+ t.Parallel()
+
+ cfg := api.ExecuteConfig{
+ DefaultMemory: 2048,
+ MaxMemory: 8192,
+ DefaultHostCalls: 16,
+ MaxHostCalls: 64,
+ DefaultHostBytes: 1024,
+ MaxHostBytes: 4096,
+ DefaultDepth: 2,
+ MaxDepth: 4,
+ }
+
+ // defaults are the limits a request without any override resolves to.
+ defaults := compute.Limits{
+ Memory: 2048,
+ MaxHostCalls: 16,
+ MaxHostBytes: 1024,
+ MaxDepth: 2,
+ }
+ with := func(f func(*compute.Limits)) compute.Limits {
+ l := defaults
+ f(&l)
+ return l
+ }
+
+ for _, tc := range []struct {
+ name string
+ headers map[string]string
+ want compute.Limits
+ }{
+ {
+ name: "defaults",
+ want: defaults,
+ },
+ {
+ name: "request below the maximum is honored",
+ headers: map[string]string{api.SwarmWasmMemoryLimitHeader: "1024"},
+ want: with(func(l *compute.Limits) { l.Memory = 1024 }),
+ },
+ {
+ name: "request above the maximum is clamped",
+ headers: map[string]string{api.SwarmWasmMemoryLimitHeader: "999999"},
+ want: with(func(l *compute.Limits) { l.Memory = 8192 }),
+ },
+ {
+ name: "entrypoint is passed through",
+ headers: map[string]string{api.SwarmWasmEntrypointHeader: "run"},
+ want: with(func(l *compute.Limits) { l.Entrypoint = "run" }),
+ },
+ {
+ name: "host calls below the maximum are honored",
+ headers: map[string]string{api.SwarmWasmHostCallsHeader: "32"},
+ want: with(func(l *compute.Limits) { l.MaxHostCalls = 32 }),
+ },
+ {
+ name: "host calls above the maximum are clamped",
+ headers: map[string]string{api.SwarmWasmHostCallsHeader: "999999"},
+ want: with(func(l *compute.Limits) { l.MaxHostCalls = 64 }),
+ },
+ {
+ name: "host bytes below the maximum are honored",
+ headers: map[string]string{api.SwarmWasmHostBytesHeader: "2048"},
+ want: with(func(l *compute.Limits) { l.MaxHostBytes = 2048 }),
+ },
+ {
+ name: "host bytes above the maximum are clamped",
+ headers: map[string]string{api.SwarmWasmHostBytesHeader: "999999"},
+ want: with(func(l *compute.Limits) { l.MaxHostBytes = 4096 }),
+ },
+ {
+ name: "depth below the maximum is honored",
+ headers: map[string]string{api.SwarmWasmDepthHeader: "1"},
+ want: with(func(l *compute.Limits) { l.MaxDepth = 1 }),
+ },
+ {
+ name: "depth above the maximum is clamped",
+ headers: map[string]string{api.SwarmWasmDepthHeader: "999999"},
+ want: with(func(l *compute.Limits) { l.MaxDepth = 4 }),
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ engine := &mockEngine{result: compute.Result{Status: compute.StatusOK}}
+ client := newExecuteTestServer(t, engine, cfg)
+ addr := uploadModule(t, client, []byte("module"))
+
+ opts := []jsonhttptest.Option{
+ jsonhttptest.WithRequestHeader(api.AcceptHeader, "application/octet-stream"),
+ jsonhttptest.WithNoResponseBody(),
+ }
+ for name, value := range tc.headers {
+ opts = append(opts, jsonhttptest.WithRequestHeader(name, value))
+ }
+
+ jsonhttptest.Request(t, client, http.MethodPost, "/@/"+addr.String(), http.StatusOK, opts...)
+
+ if engine.request.Limits != tc.want {
+ t.Errorf("got limits %+v, want %+v", engine.request.Limits, tc.want)
+ }
+ })
+ }
+}
+
+func TestExecuteInvalidRequest(t *testing.T) {
+ t.Parallel()
+
+ engine := &mockEngine{}
+ client := newExecuteTestServer(t, engine, api.ExecuteConfig{})
+
+ t.Run("invalid address", func(t *testing.T) {
+ jsonhttptest.Request(t, client, http.MethodPost, "/@/not-an-address", http.StatusBadRequest,
+ jsonhttptest.WithExpectedJSONResponse(jsonhttp.StatusResponse{
+ Message: "invalid path params",
+ Code: http.StatusBadRequest,
+ Reasons: []jsonhttp.Reason{{
+ Field: "address",
+ Error: api.HexInvalidByteError('n').Error(),
+ }},
+ }),
+ )
+ })
+
+ t.Run("invalid memory limit", func(t *testing.T) {
+ jsonhttptest.Request(t, client, http.MethodPost, "/@/"+swarm.RandAddress(t).String(), http.StatusBadRequest,
+ jsonhttptest.WithRequestHeader(api.SwarmWasmMemoryLimitHeader, "not a number"),
+ )
+ })
+
+ if engine.calls != 0 {
+ t.Errorf("got %d engine calls, want none for an invalid request", engine.calls)
+ }
+}
+
+// TestExecuteMethods checks that every HTTP method reaches the module and that
+// the module is told which one it was called with.
+func TestExecuteMethods(t *testing.T) {
+ t.Parallel()
+
+ for _, method := range []string{
+ http.MethodGet,
+ http.MethodHead,
+ http.MethodPost,
+ http.MethodPut,
+ http.MethodPatch,
+ http.MethodDelete,
+ "PROPFIND",
+ } {
+ t.Run(method, func(t *testing.T) {
+ t.Parallel()
+
+ module := []byte("this stands in for a wasm module")
+ engine := &mockEngine{result: compute.Result{
+ Status: compute.StatusOK,
+ Output: []byte("computed output"),
+ }}
+
+ client := newExecuteTestServer(t, engine, api.ExecuteConfig{})
+ addr := uploadModule(t, client, module)
+
+ opts := []jsonhttptest.Option{
+ jsonhttptest.WithRequestHeader(api.AcceptHeader, "application/octet-stream"),
+ jsonhttptest.WithExpectedResponseHeader(api.SwarmWasmStatusHeader, "ok"),
+ }
+ // A HEAD response carries no body, so only assert it elsewhere.
+ if method != http.MethodHead {
+ opts = append(opts, jsonhttptest.WithExpectedResponse([]byte("computed output")))
+ }
+ jsonhttptest.Request(t, client, method, "/@/"+addr.String(), http.StatusOK, opts...)
+
+ if engine.calls != 1 {
+ t.Fatalf("got %d engine calls, want 1", engine.calls)
+ }
+ if engine.request.Method != method {
+ t.Errorf("got method %q, want %q", engine.request.Method, method)
+ }
+ if !bytes.Equal(engine.request.Module, module) {
+ t.Errorf("got module %q, want %q", engine.request.Module, module)
+ }
+ })
+ }
+}
+
+// TestExecuteOptions checks that a CORS preflight is answered by the node and
+// never reaches the module.
+func TestExecuteOptions(t *testing.T) {
+ t.Parallel()
+
+ engine := &mockEngine{result: compute.Result{Status: compute.StatusOK}}
+ client := newExecuteTestServer(t, engine, api.ExecuteConfig{})
+
+ jsonhttptest.Request(t, client, http.MethodOptions, "/@/"+swarm.RandAddress(t).String(), http.StatusNoContent)
+
+ if engine.calls != 0 {
+ t.Errorf("got %d engine calls, want none for a preflight", engine.calls)
+ }
+}
diff --git a/pkg/api/host.go b/pkg/api/host.go
new file mode 100644
index 00000000000..8e5dd9b119d
--- /dev/null
+++ b/pkg/api/host.go
@@ -0,0 +1,202 @@
+// Copyright 2026 The Swarm Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package api
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "sync"
+
+ "github.com/ethersphere/bee/v2/pkg/cac"
+ "github.com/ethersphere/bee/v2/pkg/compute"
+ "github.com/ethersphere/bee/v2/pkg/file/joiner"
+ "github.com/ethersphere/bee/v2/pkg/file/redundancy"
+ "github.com/ethersphere/bee/v2/pkg/log"
+ "github.com/ethersphere/bee/v2/pkg/postage"
+ "github.com/ethersphere/bee/v2/pkg/storage"
+ "github.com/ethersphere/bee/v2/pkg/storer"
+ "github.com/ethersphere/bee/v2/pkg/swarm"
+ "github.com/ethersphere/bee/v2/pkg/topology"
+)
+
+// executeHost serves the swarm host calls of a single execution. It is built
+// per request in executeHandler and closed after the run, so the upload session
+// it opens spans one execution and no more.
+type executeHost struct {
+ s *Service
+ logger log.Logger
+ cache bool
+ maxBytes uint64
+
+ // sessionCtx scopes the upload session to the request rather than to the
+ // run. A host call's own context is the watchdog's, and that is cancelled
+ // the moment Execute returns — before Close commits — so a session opened
+ // on it can only ever fail to commit. The request context is what every
+ // other upload endpoint uses, and it is still live at Close.
+ sessionCtx context.Context
+
+ // mu guards the lazily opened session. A guest is single-threaded and
+ // nested executions are sequential, but the session outlives individual
+ // calls and is cheap to guard.
+ mu sync.Mutex
+ batchID []byte
+ session storer.PutterSession
+}
+
+var _ compute.Host = (*executeHost)(nil)
+
+// newExecuteHost builds the per-request host. ctx is the request's, and outlives
+// the run so the upload session can still be committed. maxBytes is the
+// execution's byte budget, used to refuse an oversized download before it is
+// materialised.
+func (s *Service) newExecuteHost(ctx context.Context, logger log.Logger, maxBytes uint64) *executeHost {
+ return &executeHost{s: s, logger: logger, cache: true, maxBytes: maxBytes, sessionCtx: ctx}
+}
+
+// BytesGet reassembles data of arbitrary length, as GET /bytes does.
+func (h *executeHost) BytesGet(ctx context.Context, addr swarm.Address) ([]byte, error) {
+ reader, l, err := joiner.New(ctx, h.s.storer.Download(h.cache), h.s.storer.Cache(), addr, redundancy.DefaultDownloadLevel)
+ if err != nil {
+ return nil, mapHostErr(err)
+ }
+ // The span is known up front, so an oversized object is refused without
+ // reading it. readCapped still bounds the read for a lying or absent span.
+ if h.maxBytes > 0 && l >= 0 && uint64(l) > h.maxBytes {
+ return nil, compute.ErrTooLarge
+ }
+ data, err := readCapped(reader, h.maxBytes)
+ if err != nil {
+ if errors.Is(err, errTooLarge) {
+ return nil, compute.ErrTooLarge
+ }
+ return nil, mapHostErr(err)
+ }
+ return data, nil
+}
+
+// BytesPut splits data of arbitrary length through the same pipeline POST
+// /bytes uses and returns the root reference.
+//
+// Encryption and redundancy are deliberately not exposed to the guest: an
+// encrypted reference is 64 bytes and the guest ABI writes a fixed 32.
+func (h *executeHost) BytesPut(ctx context.Context, batchID, data []byte) (swarm.Address, error) {
+ putter, err := h.putter(batchID)
+ if err != nil {
+ return swarm.ZeroAddress, err
+ }
+ addr, err := requestPipelineFn(putter, false, redundancy.DefaultUploadLevel)(ctx, bytes.NewReader(data))
+ if err != nil {
+ return swarm.ZeroAddress, mapHostErr(err)
+ }
+ return addr, nil
+}
+
+// ChunkGet retrieves a single chunk, as GET /chunks/{addr} does.
+func (h *executeHost) ChunkGet(ctx context.Context, addr swarm.Address) ([]byte, error) {
+ chunk, err := h.s.storer.Download(h.cache).Get(ctx, addr)
+ if err != nil {
+ return nil, mapHostErr(err)
+ }
+ return chunk.Data(), nil
+}
+
+// ChunkPut stores a single content-addressed chunk verbatim. Unlike POST
+// /chunks there is no single owner chunk path: a SOC needs a signature the
+// guest has no way to produce.
+func (h *executeHost) ChunkPut(ctx context.Context, batchID, data []byte) (swarm.Address, error) {
+ putter, err := h.putter(batchID)
+ if err != nil {
+ return swarm.ZeroAddress, err
+ }
+ chunk, err := cac.NewWithDataSpan(data)
+ if err != nil {
+ // Malformed chunk bytes are the guest's mistake, not the node's.
+ h.logger.Debug("execute host: invalid chunk data", "error", err)
+ return swarm.ZeroAddress, compute.ErrInvalid
+ }
+ if err := putter.Put(ctx, chunk); err != nil {
+ return swarm.ZeroAddress, mapHostErr(err)
+ }
+ return chunk.Address(), nil
+}
+
+// putter returns the execution's upload session, opening it on the first put so
+// a module that never uploads never creates one. It takes no context: the
+// session is scoped to sessionCtx, not to the call that happened to open it.
+//
+// One execution gets one session and therefore one batch: a put with a
+// different batch than the one that opened it is refused.
+func (h *executeHost) putter(batchID []byte) (storer.PutterSession, error) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+
+ if h.session != nil {
+ if !bytes.Equal(h.batchID, batchID) {
+ h.logger.Debug("execute host: second batch refused")
+ return nil, compute.ErrDenied
+ }
+ return h.session, nil
+ }
+
+ // The upload store rejects a zero tag, so a session id is always allocated.
+ tag, err := h.s.getOrCreateSessionID(0)
+ if err != nil {
+ return nil, err
+ }
+ session, err := h.s.newStamperPutter(h.sessionCtx, putterOptions{
+ BatchID: batchID,
+ TagID: tag,
+ // Deferred: a put returns once the chunk is stored locally and the
+ // pusher syncs it afterwards. A direct upload would block on network
+ // round trips inside a host call and burn the watchdog.
+ Deferred: true,
+ })
+ if err != nil {
+ return nil, mapHostErr(err)
+ }
+
+ h.batchID = bytes.Clone(batchID)
+ h.session = session
+ return session, nil
+}
+
+// Close finalises the upload session, if one was opened. A committed session
+// hands its chunks to the pusher; otherwise they are dropped, so a module that
+// trapped leaves nothing behind.
+func (h *executeHost) Close(commit bool) error {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+
+ if h.session == nil {
+ return nil
+ }
+ session := h.session
+ h.session = nil
+
+ if commit {
+ return session.Done(swarm.ZeroAddress)
+ }
+ return session.Cleanup()
+}
+
+// mapHostErr translates a node error into the sentinel the guest may observe.
+// Anything unrecognised is returned as-is and ends the execution as
+// StatusHostError: a node-local failure is never a program verdict.
+func mapHostErr(err error) error {
+ switch {
+ case errors.Is(err, storage.ErrNotFound), errors.Is(err, topology.ErrNotFound):
+ return errors.Join(compute.ErrNotFound, err)
+ case errors.Is(err, errBatchUnusable),
+ errors.Is(err, errInvalidPostageBatch),
+ errors.Is(err, postage.ErrNotFound),
+ errors.Is(err, postage.ErrNotUsable),
+ errors.Is(err, postage.ErrBucketFull),
+ errors.Is(err, postage.ErrInvalidBatchSignature):
+ return errors.Join(compute.ErrDenied, err)
+ default:
+ return err
+ }
+}
diff --git a/pkg/api/host_test.go b/pkg/api/host_test.go
new file mode 100644
index 00000000000..3797e8b4252
--- /dev/null
+++ b/pkg/api/host_test.go
@@ -0,0 +1,315 @@
+// Copyright 2026 The Swarm Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package api_test
+
+import (
+ "bytes"
+ "context"
+ "encoding/binary"
+ "math/big"
+ "net/http"
+ "os"
+ "path/filepath"
+ "sync/atomic"
+ "testing"
+
+ "github.com/ethersphere/bee/v2/pkg/api"
+ "github.com/ethersphere/bee/v2/pkg/compute"
+ "github.com/ethersphere/bee/v2/pkg/jsonhttp/jsonhttptest"
+ "github.com/ethersphere/bee/v2/pkg/log"
+ "github.com/ethersphere/bee/v2/pkg/postage"
+ mockpost "github.com/ethersphere/bee/v2/pkg/postage/mock"
+ "github.com/ethersphere/bee/v2/pkg/storer"
+ mockstorer "github.com/ethersphere/bee/v2/pkg/storer/mock"
+ "github.com/ethersphere/bee/v2/pkg/swarm"
+)
+
+// Guest-visible result codes of the swarm host module, mirrored from the ABI.
+const (
+ hostErrnoOK = 0
+ hostErrnoDenied = 2
+)
+
+// loadHostFixture reads a WASM fixture from the compute package, which is where
+// the guest ABI and its test modules are defined. See its testdata/README.md.
+func loadHostFixture(t *testing.T, name string) []byte {
+ t.Helper()
+
+ module, err := os.ReadFile(filepath.Join("..", "compute", "testdata", name+".wasm"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return module
+}
+
+// sessionRecorder notes how an upload session was finished. The mock storer
+// stores puts in a shared chunk store and its Cleanup is a no-op, so committing
+// and discarding look identical from the outside; this records which one the
+// handler actually chose.
+//
+// It also holds the session to the context it was opened with. The real upload
+// store batches its writes against that context, so a session opened on one
+// that dies before the run is finished can never be committed; the mock ignores
+// the context entirely, which is what let a cancelled-by-construction session
+// pass every test here and fail on a node.
+type sessionRecorder struct {
+ storer.PutterSession
+ ctx context.Context
+ done *atomic.Bool
+ cleaned *atomic.Bool
+}
+
+func (s sessionRecorder) Done(addr swarm.Address) error {
+ s.done.Store(true)
+ if err := s.ctx.Err(); err != nil {
+ return err
+ }
+ return s.PutterSession.Done(addr)
+}
+
+func (s sessionRecorder) Cleanup() error {
+ s.cleaned.Store(true)
+ if err := s.ctx.Err(); err != nil {
+ return err
+ }
+ return s.PutterSession.Cleanup()
+}
+
+// recordingStorer hands out recording upload sessions.
+type recordingStorer struct {
+ api.Storer
+ done atomic.Bool
+ cleaned atomic.Bool
+}
+
+func (r *recordingStorer) Upload(ctx context.Context, pin bool, tagID uint64) (storer.PutterSession, error) {
+ session, err := r.Storer.Upload(ctx, pin, tagID)
+ if err != nil {
+ return nil, err
+ }
+ return sessionRecorder{PutterSession: session, ctx: ctx, done: &r.done, cleaned: &r.cleaned}, nil
+}
+
+// newHostTestServer wires the real wazero engine behind the execute endpoint so
+// the host calls exercise the actual storer and postage paths.
+//
+// issuerBatch is the only batch the node will stamp with; a guest passing any
+// other must be refused rather than served.
+func newHostTestServer(t *testing.T, issuerBatch []byte) *http.Client {
+ t.Helper()
+
+ client, _ := newHostTestServerWithStorer(t, issuerBatch)
+ return client
+}
+
+func newHostTestServerWithStorer(t *testing.T, issuerBatch []byte) (*http.Client, *recordingStorer) {
+ t.Helper()
+
+ engine, err := compute.New(compute.Options{Logger: log.Noop})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ if err := engine.Close(); err != nil {
+ t.Errorf("close compute service: %v", err)
+ }
+ })
+
+ store := &recordingStorer{Storer: mockstorer.New()}
+ client, _, _, _ := newTestServer(t, testServerOptions{
+ Storer: store,
+ Logger: log.Noop,
+ Post: mockpost.New(mockpost.WithIssuer(postage.NewStampIssuer("", "", issuerBatch, big.NewInt(3), 11, 10, 1000, true))),
+ Compute: engine,
+ })
+ return client, store
+}
+
+// runModule uploads a fixture and executes it with the given input, returning
+// the raw bytes the module wrote.
+func runModule(t *testing.T, client *http.Client, fixture string, input []byte, wantStatus int, wantWasmStatus string) []byte {
+ t.Helper()
+
+ addr := uploadModule(t, client, loadHostFixture(t, fixture))
+
+ var out []byte
+ jsonhttptest.Request(t, client, http.MethodPost, "/@/"+addr.String(), wantStatus,
+ jsonhttptest.WithRequestHeader(api.AcceptHeader, "application/octet-stream"),
+ jsonhttptest.WithRequestBody(bytes.NewReader(input)),
+ jsonhttptest.WithExpectedResponseHeader(api.SwarmWasmStatusHeader, wantWasmStatus),
+ jsonhttptest.WithPutResponseBody(&out),
+ )
+ return out
+}
+
+// reset forgets the sessions used to upload the fixtures themselves, so a test
+// observes only what the execution did.
+func (r *recordingStorer) reset() {
+ r.done.Store(false)
+ r.cleaned.Store(false)
+}
+
+// hostErrno reads the leading result code a fixture writes.
+func hostErrno(t *testing.T, out []byte) uint32 {
+ t.Helper()
+
+ if len(out) < 4 {
+ t.Fatalf("output too short for a result code: %d bytes", len(out))
+ }
+ return binary.LittleEndian.Uint32(out)
+}
+
+// TestExecuteHostBytesGet uploads data through /bytes and has a module read it
+// back through swarm_bytes_get.
+func TestExecuteHostBytesGet(t *testing.T) {
+ t.Parallel()
+
+ client := newHostTestServer(t, batchOk)
+ payload := []byte("data the guest reads back out of swarm")
+ addr := uploadModule(t, client, payload)
+
+ // stdin is the address followed by the buffer length the module offers.
+ input := append(addr.Bytes(), u32le(4096)...)
+ out := runModule(t, client, "hostbytesget", input, http.StatusOK, "ok")
+
+ if errno := hostErrno(t, out); errno != hostErrnoOK {
+ t.Fatalf("errno: got %d, want %d", errno, hostErrnoOK)
+ }
+ if got := out[8:]; !bytes.Equal(got, payload) {
+ t.Errorf("payload: got %q, want %q", got, payload)
+ }
+}
+
+// TestExecuteHostBytesPut has a module upload data and then fetches the
+// reference it returned over /bytes, which only resolves if the deferred
+// session was committed after the run.
+func TestExecuteHostBytesPut(t *testing.T) {
+ t.Parallel()
+
+ client, store := newHostTestServerWithStorer(t, batchOk)
+ payload := []byte("data the guest wrote into swarm")
+
+ addr := uploadModule(t, client, loadHostFixture(t, "hostbytesput"))
+ store.reset()
+
+ var out []byte
+ jsonhttptest.Request(t, client, http.MethodPost, "/@/"+addr.String(), http.StatusOK,
+ jsonhttptest.WithRequestHeader(api.AcceptHeader, "application/octet-stream"),
+ jsonhttptest.WithRequestBody(bytes.NewReader(append(batchOk, payload...))),
+ jsonhttptest.WithExpectedResponseHeader(api.SwarmWasmStatusHeader, "ok"),
+ jsonhttptest.WithPutResponseBody(&out),
+ )
+
+ if errno := hostErrno(t, out); errno != hostErrnoOK {
+ t.Fatalf("errno: got %d, want %d", errno, hostErrnoOK)
+ }
+ if !store.done.Load() {
+ t.Error("upload session was not committed after a clean run")
+ }
+ if store.cleaned.Load() {
+ t.Error("upload session was discarded after a clean run")
+ }
+
+ ref := swarm.NewAddress(out[4:])
+ if len(out[4:]) != swarm.HashSize {
+ t.Fatalf("reference length: got %d, want %d", len(out[4:]), swarm.HashSize)
+ }
+
+ jsonhttptest.Request(t, client, http.MethodGet, "/bytes/"+ref.String(), http.StatusOK,
+ jsonhttptest.WithExpectedResponse(payload),
+ )
+}
+
+// TestExecuteHostBadBatch checks that an unusable batch is reported to the
+// module as a result code, not surfaced as a node failure.
+func TestExecuteHostBadBatch(t *testing.T) {
+ t.Parallel()
+
+ client := newHostTestServer(t, batchOk)
+
+ // A batch the node issues nothing for. The run itself is fine; only the
+ // upload is refused, so the endpoint answers 200 and the module reports it.
+ otherBatch := bytes.Repeat([]byte{9}, swarm.HashSize)
+ out := runModule(t, client, "hostbytesput", append(otherBatch, []byte("payload")...), http.StatusOK, "ok")
+
+ if errno := hostErrno(t, out); errno != hostErrnoDenied {
+ t.Errorf("errno: got %d, want %d", errno, hostErrnoDenied)
+ }
+}
+
+// TestExecuteHostTrapDiscardsUpload checks that a module which uploads and then
+// traps leaves nothing behind: its chunks are dropped rather than handed to the
+// pusher.
+func TestExecuteHostTrapDiscardsUpload(t *testing.T) {
+ t.Parallel()
+
+ client, store := newHostTestServerWithStorer(t, batchOk)
+ payload := []byte("data that must not survive the trap")
+ addr := uploadModule(t, client, loadHostFixture(t, "hostputtrap"))
+ store.reset()
+
+ // A trap is a program verdict, so the endpoint answers 400. The JSON
+ // envelope still carries what the module wrote before trapping, which
+ // includes the reference its upload returned.
+ var resp struct {
+ Status string `json:"status"`
+ Output []byte `json:"output"`
+ }
+ jsonhttptest.Request(t, client, http.MethodPost, "/@/"+addr.String(), http.StatusBadRequest,
+ jsonhttptest.WithRequestHeader(api.AcceptHeader, "application/json"),
+ jsonhttptest.WithRequestBody(bytes.NewReader(append(batchOk, payload...))),
+ jsonhttptest.WithExpectedResponseHeader(api.SwarmWasmStatusHeader, "trap"),
+ jsonhttptest.WithUnmarshalJSONResponse(&resp),
+ )
+
+ if errno := hostErrno(t, resp.Output); errno != hostErrnoOK {
+ t.Fatalf("errno: got %d, want %d", errno, hostErrnoOK)
+ }
+ if len(resp.Output[4:]) != swarm.HashSize {
+ t.Fatalf("reference length: got %d, want %d", len(resp.Output[4:]), swarm.HashSize)
+ }
+
+ if store.done.Load() {
+ t.Error("upload session was committed after a trapped run")
+ }
+ if !store.cleaned.Load() {
+ t.Error("upload session was not discarded after a trapped run")
+ }
+}
+
+// u32le encodes a little-endian uint32 the way the fixtures read them.
+func u32le(v uint32) []byte {
+ b := make([]byte, 4)
+ binary.LittleEndian.PutUint32(b, v)
+ return b
+}
+
+// TestExecuteHostByteLimit checks that an object larger than the execution's
+// byte budget is refused by the node before it is read into memory, and that
+// the module sees that as a result code rather than a failure.
+func TestExecuteHostByteLimit(t *testing.T) {
+ t.Parallel()
+
+ const hostErrnoBudgetExhausted = 3
+
+ client := newHostTestServer(t, batchOk)
+ payload := bytes.Repeat([]byte("x"), 4096)
+ target := uploadModule(t, client, payload)
+ module := uploadModule(t, client, loadHostFixture(t, "hostbytesget"))
+
+ var out []byte
+ jsonhttptest.Request(t, client, http.MethodPost, "/@/"+module.String(), http.StatusOK,
+ jsonhttptest.WithRequestHeader(api.AcceptHeader, "application/octet-stream"),
+ // Far less than the payload, so the download is refused up front.
+ jsonhttptest.WithRequestHeader(api.SwarmWasmHostBytesHeader, "64"),
+ jsonhttptest.WithRequestBody(bytes.NewReader(append(target.Bytes(), u32le(4096)...))),
+ jsonhttptest.WithExpectedResponseHeader(api.SwarmWasmStatusHeader, "ok"),
+ jsonhttptest.WithPutResponseBody(&out),
+ )
+
+ if errno := hostErrno(t, out); errno != hostErrnoBudgetExhausted {
+ t.Errorf("errno: got %d, want %d", errno, hostErrnoBudgetExhausted)
+ }
+}
diff --git a/pkg/api/router.go b/pkg/api/router.go
index 941c63fc89e..4aee6efb1a7 100644
--- a/pkg/api/router.go
+++ b/pkg/api/router.go
@@ -199,6 +199,16 @@ func (s *Service) checkSwapAvailability(handler http.Handler) http.Handler {
})
}
+func (s *Service) checkExecuteAvailability(handler http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if s.compute == nil {
+ jsonhttp.Forbidden(w, "WASM execution is disabled. This endpoint is unavailable.")
+ return
+ }
+ handler.ServeHTTP(w, r)
+ })
+}
+
func (s *Service) checkChequebookAvailability(handler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !s.chequebookEnabled {
@@ -262,6 +272,23 @@ func (s *Service) mountAPI() {
),
})
+ // Registered without a jsonhttp.MethodHandler on purpose: every HTTP method
+ // reaches the module, which is told which one it was called with.
+ //
+ // Both the bare and the trailing-path form serve. Unlike /bzz, which redirects
+ // the bare form to the directory form, /@/{address} is a function invocation:
+ // POST /@/{address} with a body is how a module is called, and most modules
+ // are pure compute for which a trailing slash means nothing. A module that
+ // wants the /bzz behaviour issues the redirect itself, using SCRIPT_NAME.
+ executeHandler := web.ChainHandlers(
+ s.checkExecuteAvailability,
+ s.contentLengthMetricMiddleware(),
+ s.newTracingHandler("execute"),
+ web.FinalHandlerFunc(s.executeHandler),
+ )
+ handle("/@/{address}", executeHandler)
+ handle("/@/{address}/{path:.*}", executeHandler)
+
handle("/bytes/{address}", jsonhttp.MethodHandler{
"GET": web.ChainHandlers(
s.contentLengthMetricMiddleware(),
diff --git a/pkg/api/status.go b/pkg/api/status.go
index c1eaacb49c0..c45b30c1d98 100644
--- a/pkg/api/status.go
+++ b/pkg/api/status.go
@@ -32,6 +32,7 @@ type statusSnapshotResponse struct {
LastSyncedBlock uint64 `json:"lastSyncedBlock"`
CommittedDepth uint8 `json:"committedDepth"`
IsWarmingUp bool `json:"isWarmingUp"`
+ IsWasmEnabled bool `json:"isWasmEnabled"`
}
type statusResponse struct {
@@ -92,6 +93,7 @@ func (s *Service) statusGetHandler(w http.ResponseWriter, _ *http.Request) {
LastSyncedBlock: ss.LastSyncedBlock,
CommittedDepth: uint8(ss.CommittedDepth),
IsWarmingUp: s.isWarmingUp,
+ IsWasmEnabled: s.compute != nil,
})
}
diff --git a/pkg/api/status_test.go b/pkg/api/status_test.go
index 9da5797caf8..19bf5a29cb8 100644
--- a/pkg/api/status_test.go
+++ b/pkg/api/status_test.go
@@ -10,6 +10,7 @@ import (
"testing"
"github.com/ethersphere/bee/v2/pkg/api"
+ "github.com/ethersphere/bee/v2/pkg/compute"
"github.com/ethersphere/bee/v2/pkg/jsonhttp/jsonhttptest"
"github.com/ethersphere/bee/v2/pkg/log"
"github.com/ethersphere/bee/v2/pkg/postage"
@@ -24,57 +25,68 @@ func TestGetStatus(t *testing.T) {
const url = "/status"
- t.Run("node", func(t *testing.T) {
- t.Parallel()
-
- mode := api.FullMode
- ssr := api.StatusSnapshotResponse{
- Proximity: 256,
- BeeMode: mode.String(),
- ReserveSize: 128,
- ReserveSizeWithinRadius: 64,
- PullsyncRate: 64,
- StorageRadius: 8,
- ConnectedPeers: 0,
- NeighborhoodSize: 1,
- BatchCommitment: 1,
- IsReachable: true,
- LastSyncedBlock: 6092500,
- CommittedDepth: 1,
- }
-
- ssMock := &statusSnapshotMock{
- syncRate: ssr.PullsyncRate,
- reserveSize: int(ssr.ReserveSize),
- reserveSizeWithinRadius: ssr.ReserveSizeWithinRadius,
- storageRadius: ssr.StorageRadius,
- commitment: ssr.BatchCommitment,
- chainState: &postage.ChainState{Block: ssr.LastSyncedBlock},
- committedDepth: ssr.CommittedDepth,
- }
-
- statusSvc := status.NewService(
- log.Noop,
- nil,
- new(topologyPeersIterNoopMock),
- mode.String(),
- ssMock,
- ssMock,
- nil,
- )
-
- statusSvc.SetSync(ssMock)
-
- client, _, _, _ := newTestServer(t, testServerOptions{
- BeeMode: mode,
- NodeStatus: statusSvc,
+ for _, tc := range []struct {
+ name string
+ engine compute.Engine
+ }{
+ {name: "node"},
+ // The WASM engine is only wired up when the execute endpoint is enabled,
+ // so its presence is what the flag reports.
+ {name: "node with wasm engine", engine: new(mockEngine)},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ mode := api.FullMode
+ ssr := api.StatusSnapshotResponse{
+ Proximity: 256,
+ BeeMode: mode.String(),
+ ReserveSize: 128,
+ ReserveSizeWithinRadius: 64,
+ PullsyncRate: 64,
+ StorageRadius: 8,
+ ConnectedPeers: 0,
+ NeighborhoodSize: 1,
+ BatchCommitment: 1,
+ IsReachable: true,
+ LastSyncedBlock: 6092500,
+ CommittedDepth: 1,
+ IsWasmEnabled: tc.engine != nil,
+ }
+
+ ssMock := &statusSnapshotMock{
+ syncRate: ssr.PullsyncRate,
+ reserveSize: int(ssr.ReserveSize),
+ reserveSizeWithinRadius: ssr.ReserveSizeWithinRadius,
+ storageRadius: ssr.StorageRadius,
+ commitment: ssr.BatchCommitment,
+ chainState: &postage.ChainState{Block: ssr.LastSyncedBlock},
+ committedDepth: ssr.CommittedDepth,
+ }
+
+ statusSvc := status.NewService(
+ log.Noop,
+ nil,
+ new(topologyPeersIterNoopMock),
+ mode.String(),
+ ssMock,
+ ssMock,
+ nil,
+ )
+
+ statusSvc.SetSync(ssMock)
+
+ client, _, _, _ := newTestServer(t, testServerOptions{
+ BeeMode: mode,
+ NodeStatus: statusSvc,
+ Compute: tc.engine,
+ })
+
+ jsonhttptest.Request(t, client, http.MethodGet, url, http.StatusOK,
+ jsonhttptest.WithExpectedJSONResponse(ssr),
+ )
})
-
- jsonhttptest.Request(t, client, http.MethodGet, url, http.StatusOK,
- jsonhttptest.WithExpectedJSONResponse(ssr),
- )
- })
-
+ }
}
// TestGetStatusPeersIncludesBootnodes is a regression test for
diff --git a/pkg/compute/README.md b/pkg/compute/README.md
new file mode 100644
index 00000000000..377336421aa
--- /dev/null
+++ b/pkg/compute/README.md
@@ -0,0 +1,243 @@
+# The `swarm` guest ABI
+
+A module executed through `/@/{address}` — any method, with an optional trailing
+path — runs in a WASI sandbox. Alongside
+`wasi_snapshot_preview1` it may import a host module named `swarm`, through which
+it reaches the node it is running on.
+
+> **Experimental.** Output is not reproducible across nodes: a host call reads
+> what *this* node happens to hold. There is no gas metering and no process
+> boundary. Node work is bounded by budgets, not by a work-based limit. Do not
+> enable the endpoint on a public gateway.
+
+## Functions
+
+```wat
+(import "swarm" "swarm_bytes_get"
+ (func (param i32 i32 i32 i32) (result i32))) ;; addr_ptr, buf_ptr, buf_len, out_len_ptr
+(import "swarm" "swarm_bytes_put"
+ (func (param i32 i32 i32 i32) (result i32))) ;; batch_ptr, data_ptr, data_len, out_addr_ptr
+(import "swarm" "swarm_chunk_get"
+ (func (param i32 i32 i32 i32) (result i32))) ;; addr_ptr, buf_ptr, buf_len, out_len_ptr
+(import "swarm" "swarm_chunk_put"
+ (func (param i32 i32 i32 i32) (result i32))) ;; batch_ptr, data_ptr, data_len, out_addr_ptr
+(import "swarm" "swarm_execute"
+ (func (param i32 i32 i32 i32 i32 i32) (result i32)));; addr_ptr, input_ptr, input_len, buf_ptr, buf_len, out_len_ptr
+(import "swarm" "swarm_response_status"
+ (func (param i32) (result i32))) ;; code
+(import "swarm" "swarm_response_header"
+ (func (param i32 i32 i32 i32) (result i32))) ;; name_ptr, name_len, val_ptr, val_len
+```
+
+The module is defined in two halves. The five data functions above exist only
+when the node has node access; the two response functions always do, because
+shaping a response causes no node work. A module that only sets a Content-Type
+therefore runs on a node with node access switched off.
+
+`bytes_*` moves data of arbitrary length through the same splitter and joiner
+the `/bytes` endpoints use. `chunk_*` is the raw single-chunk pair: `chunk_put`
+takes at most 4104 bytes (an 8-byte span followed by up to 4096 bytes of data)
+and `chunk_get` yields a chunk's data verbatim.
+
+Addresses, references and batch ids are always 32 bytes, so they carry no length
+argument. `out_addr_ptr` must have 32 writable bytes.
+
+Importing a name the host module does not define, or any module other than
+`swarm` and `wasi_snapshot_preview1`, is rejected before the module runs — the
+result is `invalid-module`, never a trap partway through. Importing a data
+function on a node without node access is rejected the same way.
+
+Note what is *not* checked: a module need not export its memory. It will not get
+far without one — every call that moves bytes answers `INVALID` when there is no
+memory to read — but that is a result code, not a rejection.
+
+## Result codes
+
+Every function returns a code rather than trapping, so a module can react to a
+refusal instead of dying:
+
+| Code | Name | Meaning |
+|---|---|---|
+| 0 | `OK` | the call succeeded |
+| 1 | `NOT_FOUND` | nothing is stored at that address |
+| 2 | `DENIED` | the node refused: an unusable postage batch, or a second batch in one execution |
+| 3 | `BUDGET_EXHAUSTED` | the call, byte or depth budget is spent |
+| 4 | `BUFFER_TOO_SMALL` | the payload does not fit `buf_len`; the required length is at `out_len_ptr` |
+| 5 | `INVALID` | a pointer is out of bounds, or the arguments are malformed |
+| 6 | `EXEC_FAILED` | `swarm_execute` ran the nested module and it trapped or was invalid |
+
+A pointer outside linear memory is `INVALID`, not a trap: the host bounds-checks
+every offset it is given.
+
+Failures **inside the node** — the storer erroring, the watchdog firing — are
+never reported through these codes. They end the execution with status
+`host-error` and a 500, because a node-local failure is not a verdict on the
+program.
+
+## Reading data: the two-call pattern
+
+The caller provides the buffer, so the host never grows guest memory mid-call.
+`out_len_ptr` always receives the required length, including when the buffer was
+too small, which gives the usual probe-then-fetch pattern without a second entry
+point:
+
+```wat
+;; probe with no buffer to learn the size
+(call $bytes_get (local.get $addr) (i32.const 0) (i32.const 0) (i32.const 36))
+;; ... allocate (i32.load (i32.const 36)) bytes, then ask again
+(call $bytes_get (local.get $addr) (local.get $buf) (local.get $len) (i32.const 36))
+```
+
+A probe costs one host call but no bytes: the byte budget is charged only on
+delivery.
+
+## Uploads
+
+The guest supplies the postage batch id, which it can only have received as
+input — it has no way to enumerate the node's batches. The node resolves it
+exactly as `POST /chunks` does, so the WASM path grants no authority the HTTP
+API does not already grant. An unusable or unknown batch is `DENIED`.
+
+One execution gets **one upload session and therefore one batch**: a put naming a
+different batch than the first is `DENIED`.
+
+Uploads are **deferred**. A put returns once the chunk is stored locally and the
+pusher syncs it afterwards, so a host call never blocks on network round trips.
+Two consequences:
+
+- When the HTTP response returns, the data is in the local upload store but not
+ yet acknowledged by the network.
+- The session is committed only if the execution succeeds. A module that traps,
+ or is cut off, leaves nothing behind.
+
+Encryption and redundancy are not exposed: `bytes_put` always writes
+unencrypted at the default redundancy level, which is what keeps a reference
+32 bytes wide.
+
+## Nested execution
+
+`swarm_execute` fetches a module from Swarm and runs it, handing it `input` on
+stdin and returning its stdout. The budgets are **shared across the whole call
+tree**, so a module cannot multiply its allowance by recursing. Nesting is
+bounded by the depth limit; a cycle simply runs out of depth. The nested
+module also inherits the Request metadata, but it is not allowed to shape the
+response.
+
+A nested module is always run as a WASI command: the caller's
+`Swarm-Wasm-Entrypoint` applies to the outermost module only.
+
+## Shaping the response
+
+By default the node decides how a module's output is rendered: the status comes
+from the verdict and the content type from the caller's `Accept`. A module that
+serves a web page needs to decide both itself, so it may set them:
+
+```wat
+(call $response_status (i32.const 404))
+(call $response_header (local.get $name) (i32.const 12) (local.get $value) (i32.const 8))
+```
+
+Both return a result code and neither charges the host-call budget, because
+neither causes the node to do any work. They have their own bounds instead —
+32 headers and 8 KiB of name-plus-value by default, `BUDGET_EXHAUSTED` beyond
+that — and hard per-field caps of 128 bytes for a name and 4 KiB for a value.
+Lengths are checked before any guest memory is read, so an absurd `val_len` costs
+nothing.
+
+Rules worth knowing:
+
+- **Only the outermost execution has a response.** A module reached through
+ `swarm_execute` is a library call, not an HTTP request, so both functions
+ answer `DENIED` there rather than letting a fetched module rewrite its caller's
+ content type.
+- **Only a clean run commits.** A module that traps sets no headers, exactly as
+ it stores nothing. Its partial *output* does survive — that is evidence about
+ what went wrong, while a header would be an instruction to follow.
+- **A status must be 200–599.** 1xx is refused because an informational status
+ desynchronises the connection. 5xx is allowed: a module must be able to report
+ its own failure, and `Swarm-Wasm-Status` still says `ok`, which is what
+ distinguishes the module's 500 from the node's.
+- **Some names are refused** with `DENIED`: `Swarm-Wasm-*` (the node's verdict
+ channel must not be forgeable), `Access-Control-*` (the node sets
+ `Access-Control-Allow-Credentials`, so a guest widening CORS would be a real
+ cross-origin credential leak), `Set-Cookie` and the origin-wide security
+ headers (every module shares the node's origin with its authenticated API), and
+ the hop-by-hop and framing headers. A malformed name or a value containing a
+ control character is `INVALID`, which subsumes CR/LF injection.
+
+How the metadata is rendered depends on what the caller asked for. `Accept:
+application/json` reports it in the envelope as `httpStatus` and `headers`
+without applying it — a client that asked to be told about the run did not ask to
+have its transport reshaped. Every other representation applies it, the guest's
+`Content-Type` replacing the negotiated default. A wildcard `Accept` reports by
+default and applies once the module has set something, which is what lets a
+browser load a stylesheet: a subresource request never names `text/html`.
+
+## Request metadata
+
+Exposed CGI-style. The host environment is never inherited, so a module sees the
+same variables on every node.
+
+| Variable | Value |
+|---|---|
+| `REQUEST_METHOD` | the HTTP method the endpoint was called with |
+| `SCRIPT_NAME` | the mount point, e.g. `/@/{address}` — what a module builds self-links from |
+| `PATH_INFO` | the path after the address: empty for `/@/a`, `/` for `/@/a/`, `/x/y` for `/@/a/x/y` |
+| `QUERY_STRING` | the raw, undecoded query |
+| `REQUEST_URI` | the full request target |
+| `CONTENT_TYPE`, `CONTENT_LENGTH` | of the request body |
+| `HTTP_*` | allowlisted request headers, upper-cased with `-` replaced by `_` |
+
+Both `/@/{address}` and `/@/{address}/{path}` serve. Unlike `/bzz`, the bare form
+is not redirected to the trailing-slash form: `POST /@/{address}` is how a module
+is invoked, and most modules are pure compute for which a trailing slash means
+nothing. A module that wants that redirect issues it itself, from `SCRIPT_NAME`.
+
+Request headers are an **allowlist**, the mirror image of the response denylist:
+a response header carries only what the guest already knows, while a request
+header carries what the operator's clients send. The default list is `Accept`,
+`Accept-Language`, `Host`, `If-None-Match`, `If-Modified-Since`, `Range`,
+`Referer`, `User-Agent`, `X-Requested-With` and `Swarm-Postage-Batch-Id`.
+`--wasm-request-headers` replaces it. `Authorization`, `Proxy-Authorization` and
+`Cookie` can never be forwarded whatever is configured, and a configuration
+naming one stops the node at startup.
+
+The whole environment is capped (16 KiB by default). Overflow is a `431`, not a
+truncation: a shortened environment would be a lie the module cannot detect.
+
+## Budgets
+
+| Bound | Default | Header | Stops |
+|---|---|---|---|
+| host calls | 64 | `Swarm-Wasm-Host-Calls-Limit` | fetch amplification |
+| host bytes | 32 MiB | `Swarm-Wasm-Host-Bytes-Limit` | memory and bandwidth blowup |
+| depth | 4 | `Swarm-Wasm-Depth-Limit` | runaway recursion |
+| response headers | 32 | — | unbounded response metadata |
+| response header bytes | 8 KiB | — | the same, by size |
+
+The two response bounds carry no request header. The per-request overrides exist
+so a *caller* can lower risk it is exposed to, and a caller is not exposed to
+these: they cost it nothing, and lowering them could only break the module.
+
+Headers may only lower a limit; the operator's configured maximum wins. The byte
+budget is one pool counting both directions — what the node hands the guest and
+what it accepts from it. An upload is charged its declared length before the
+splitter runs, so an oversized put is refused without the node ever chunking it.
+
+The depth limit counts execution levels including the outermost, so `1` permits
+no nesting at all.
+
+## WASI
+
+The whole of `wasi_snapshot_preview1` is available, `random_get` and
+`clock_time_get` included, which is what lets modules built by Rust std, TinyGo
+and Go run without special builds. That is a prototype convenience, not a
+portability guarantee: a deterministic engine would restrict this surface.
+
+Request metadata is exposed through the environment, as described above.
+
+## Examples
+
+Hand-written fixtures covering every call and result code live in
+[`testdata/`](testdata/), with a table of their stdin and stdout layouts in
+[`testdata/README.md`](testdata/README.md).
diff --git a/pkg/compute/compute.go b/pkg/compute/compute.go
new file mode 100644
index 00000000000..448c412acd4
--- /dev/null
+++ b/pkg/compute/compute.go
@@ -0,0 +1,87 @@
+// Copyright 2026 The Swarm Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package compute
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ "github.com/ethersphere/bee/v2/pkg/log"
+ "golang.org/x/sync/semaphore"
+)
+
+// loggerName is the tree path name of the logger for this package.
+const loggerName = "compute"
+
+// ErrBusy is returned when all execution workers are occupied.
+var ErrBusy = errors.New("compute: all workers busy")
+
+// Options configures the compute Service.
+type Options struct {
+ // Workers bounds the number of concurrent executions. Values < 1 become 1.
+ Workers int
+ // Watchdog is a wall-clock safety timeout that kills a hung execution. It is
+ // NOT a deterministic budget and a kill yields StatusHostError.
+ Watchdog time.Duration
+ // Logger is used for operator diagnostics.
+ Logger log.Logger
+}
+
+// Service is the node-facing execution service. It bounds concurrency and
+// applies the watchdog around an Engine.
+//
+// Phase 0 delegates to an in-process wazero engine; the Engine boundary lets a
+// later phase swap in the deterministic out-of-process worker without touching
+// callers.
+type Service struct {
+ engine Engine
+ sem *semaphore.Weighted
+ watchdog time.Duration
+ logger log.Logger
+}
+
+// New constructs a compute Service.
+func New(o Options) (*Service, error) {
+ if o.Workers < 1 {
+ o.Workers = 1
+ }
+ logger := o.Logger
+ if logger == nil {
+ logger = log.Noop
+ }
+ logger = logger.WithName(loggerName).Register()
+
+ logger.Warning("wasm execute is experimental: the phase-0 engine does not enforce deterministic gas and its output is not reproducible across nodes")
+
+ return &Service{
+ engine: newWazeroEngine(logger),
+ sem: semaphore.NewWeighted(int64(o.Workers)),
+ watchdog: o.Watchdog,
+ logger: logger,
+ }, nil
+}
+
+// Execute runs a module, bounding concurrency and applying the watchdog timeout.
+// It returns ErrBusy without blocking when no worker slot is free.
+func (s *Service) Execute(ctx context.Context, req Request) (Result, error) {
+ if !s.sem.TryAcquire(1) {
+ return Result{}, ErrBusy
+ }
+ defer s.sem.Release(1)
+
+ if s.watchdog > 0 {
+ var cancel context.CancelFunc
+ ctx, cancel = context.WithTimeout(ctx, s.watchdog)
+ defer cancel()
+ }
+
+ return s.engine.Execute(ctx, req)
+}
+
+// Close releases engine resources.
+func (s *Service) Close() error {
+ return s.engine.Close()
+}
diff --git a/pkg/compute/compute_test.go b/pkg/compute/compute_test.go
new file mode 100644
index 00000000000..478dec5b8d6
--- /dev/null
+++ b/pkg/compute/compute_test.go
@@ -0,0 +1,305 @@
+// Copyright 2026 The Swarm Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package compute_test
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/ethersphere/bee/v2/pkg/compute"
+)
+
+// loadModule reads a WASM fixture. See testdata/README.md for their sources.
+func loadModule(t *testing.T, name string) []byte {
+ t.Helper()
+
+ module, err := os.ReadFile(filepath.Join("testdata", name+".wasm"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return module
+}
+
+func newService(t *testing.T, o compute.Options) *compute.Service {
+ t.Helper()
+
+ if o.Watchdog == 0 {
+ o.Watchdog = 10 * time.Second
+ }
+ s, err := compute.New(o)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ if err := s.Close(); err != nil {
+ t.Errorf("close compute service: %v", err)
+ }
+ })
+ return s
+}
+
+func TestExecute(t *testing.T) {
+ t.Parallel()
+
+ for _, tc := range []struct {
+ name string
+ module string
+ input []byte
+ limits compute.Limits
+ want compute.Status
+ output string
+ }{
+ {
+ name: "writes to stdout",
+ module: "writer",
+ want: compute.StatusOK,
+ output: "hello swarm",
+ },
+ {
+ name: "input is echoed back",
+ module: "echo",
+ input: []byte("swarm input"),
+ want: compute.StatusOK,
+ output: "swarm input",
+ },
+ {
+ name: "explicit entrypoint",
+ module: "entrypoint",
+ limits: compute.Limits{Entrypoint: "run"},
+ want: compute.StatusOK,
+ output: "entrypoint output",
+ },
+ {
+ name: "entrypoint not exported",
+ module: "writer",
+ limits: compute.Limits{Entrypoint: "missing"},
+ want: compute.StatusInvalidModule,
+ },
+ {
+ // Without an explicit entrypoint the module has no WASI command
+ // entry to run.
+ name: "no start function",
+ module: "entrypoint",
+ want: compute.StatusInvalidModule,
+ },
+ {
+ name: "unreachable traps",
+ module: "trap",
+ want: compute.StatusTrap,
+ },
+ {
+ name: "non-zero exit traps",
+ module: "exit1",
+ want: compute.StatusTrap,
+ },
+ {
+ name: "unsupported import",
+ module: "badimport",
+ want: compute.StatusInvalidModule,
+ },
+ {
+ name: "memory over the limit",
+ module: "bigmem",
+ limits: compute.Limits{Memory: 64 * 1024},
+ want: compute.StatusInvalidModule,
+ },
+ {
+ name: "memory within the limit",
+ module: "bigmem",
+ limits: compute.Limits{Memory: 16 * 1024 * 1024},
+ want: compute.StatusOK,
+ output: "big",
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ s := newService(t, compute.Options{Workers: 1})
+
+ res, err := s.Execute(context.Background(), compute.Request{
+ Module: loadModule(t, tc.module),
+ Input: tc.input,
+ Limits: tc.limits,
+ })
+ if err != nil {
+ t.Fatalf("execute: %v", err)
+ }
+ if res.Status != tc.want {
+ t.Errorf("got status %v, want %v (trap message: %q)", res.Status, tc.want, res.TrapMessage)
+ }
+ if string(res.Output) != tc.output {
+ t.Errorf("got output %q, want %q", res.Output, tc.output)
+ }
+ if tc.want != compute.StatusOK && res.TrapMessage == "" {
+ t.Error("want a trap message explaining the verdict")
+ }
+ })
+ }
+}
+
+func TestExecuteInvalidModule(t *testing.T) {
+ t.Parallel()
+
+ s := newService(t, compute.Options{Workers: 1})
+
+ res, err := s.Execute(context.Background(), compute.Request{Module: []byte("this is not a wasm module")})
+ if err != nil {
+ t.Fatalf("execute: %v", err)
+ }
+ if res.Status != compute.StatusInvalidModule {
+ t.Errorf("got status %v, want %v", res.Status, compute.StatusInvalidModule)
+ }
+}
+
+// TestExecuteWatchdog checks that a module which never terminates is killed and
+// reported as an infrastructure failure rather than as a program verdict: the
+// kill depends on this node's wall clock, so it is not reproducible elsewhere.
+func TestExecuteWatchdog(t *testing.T) {
+ t.Parallel()
+
+ s := newService(t, compute.Options{Workers: 1, Watchdog: 100 * time.Millisecond})
+
+ res, err := s.Execute(context.Background(), compute.Request{Module: loadModule(t, "infloop")})
+ if err == nil {
+ t.Fatal("want an error for a watchdog kill")
+ }
+ if res.Status != compute.StatusHostError {
+ t.Errorf("got status %v, want %v", res.Status, compute.StatusHostError)
+ }
+}
+
+func TestExecuteContextCanceled(t *testing.T) {
+ t.Parallel()
+
+ s := newService(t, compute.Options{Workers: 1})
+
+ ctx, cancel := context.WithCancel(context.Background())
+ go func() {
+ time.Sleep(50 * time.Millisecond)
+ cancel()
+ }()
+
+ res, err := s.Execute(ctx, compute.Request{Module: loadModule(t, "infloop")})
+ if err == nil {
+ t.Fatal("want an error for a canceled execution")
+ }
+ if res.Status != compute.StatusHostError {
+ t.Errorf("got status %v, want %v", res.Status, compute.StatusHostError)
+ }
+}
+
+func TestExecuteBusy(t *testing.T) {
+ t.Parallel()
+
+ // With a single worker and a module that runs until the watchdog fires,
+ // whichever execution acquires the worker holds it for the duration, so the
+ // other must be rejected immediately instead of queueing behind it.
+ s := newService(t, compute.Options{Workers: 1, Watchdog: time.Second})
+ module := loadModule(t, "infloop")
+
+ errs := make(chan error, 2)
+ for i := 0; i < 2; i++ {
+ go func() {
+ _, err := s.Execute(context.Background(), compute.Request{Module: module})
+ errs <- err
+ }()
+ }
+
+ var busy int
+ for i := 0; i < 2; i++ {
+ if errors.Is(<-errs, compute.ErrBusy) {
+ busy++
+ }
+ }
+ if busy != 1 {
+ t.Errorf("got %d executions rejected as busy, want exactly 1", busy)
+ }
+}
+
+// TestExecuteNoStateLeak checks that consecutive executions of the same module
+// do not observe each other's state.
+func TestExecuteNoStateLeak(t *testing.T) {
+ t.Parallel()
+
+ s := newService(t, compute.Options{Workers: 2})
+ module := loadModule(t, "echo")
+
+ for _, input := range []string{"first", "second", "third"} {
+ res, err := s.Execute(context.Background(), compute.Request{Module: module, Input: []byte(input)})
+ if err != nil {
+ t.Fatalf("execute %q: %v", input, err)
+ }
+ if res.Status != compute.StatusOK {
+ t.Fatalf("got status %v, want %v", res.Status, compute.StatusOK)
+ }
+ if string(res.Output) != input {
+ t.Errorf("got output %q, want %q", res.Output, input)
+ }
+ }
+}
+
+func TestExecuteRequestMethod(t *testing.T) {
+ t.Parallel()
+
+ for _, tc := range []struct {
+ name string
+ method string
+ want string
+ }{
+ {name: "post", method: "POST", want: "REQUEST_METHOD=POST"},
+ {name: "get", method: "GET", want: "REQUEST_METHOD=GET"},
+ {name: "custom", method: "PROPFIND", want: "REQUEST_METHOD=PROPFIND"},
+ {
+ // No method means no environment at all, so the guest must not see
+ // a stray entry from the host.
+ name: "unset",
+ method: "",
+ want: "",
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ s := newService(t, compute.Options{Workers: 1})
+
+ res, err := s.Execute(context.Background(), compute.Request{
+ Module: loadModule(t, "method"),
+ Method: tc.method,
+ })
+ if err != nil {
+ t.Fatalf("execute: %v", err)
+ }
+ if res.Status != compute.StatusOK {
+ t.Fatalf("got status %v, want %v (trap message: %q)", res.Status, compute.StatusOK, res.TrapMessage)
+ }
+ if string(res.Output) != tc.want {
+ t.Errorf("got output %q, want %q", res.Output, tc.want)
+ }
+ })
+ }
+}
+
+func TestStatusString(t *testing.T) {
+ t.Parallel()
+
+ for _, tc := range []struct {
+ status compute.Status
+ want string
+ }{
+ {compute.StatusOK, "ok"},
+ {compute.StatusTrap, "trap"},
+ {compute.StatusInvalidModule, "invalid-module"},
+ {compute.StatusHostError, "host-error"},
+ {compute.Status(0), "unknown"},
+ } {
+ if got := tc.status.String(); got != tc.want {
+ t.Errorf("got %q, want %q", got, tc.want)
+ }
+ }
+}
diff --git a/pkg/compute/engine.go b/pkg/compute/engine.go
new file mode 100644
index 00000000000..0f083b654b8
--- /dev/null
+++ b/pkg/compute/engine.go
@@ -0,0 +1,151 @@
+// Copyright 2026 The Swarm Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Package compute runs untrusted WebAssembly modules downloaded from Swarm in a
+// sandboxed execution engine and returns the result of the computation.
+//
+// This is an experimental prototype. Modules run in-process with wazero and may
+// call back into the node through the swarm host module to read and write Swarm
+// data (see Host). Two properties the production design calls for are therefore
+// absent, deferred rather than rejected:
+//
+// - Output is NOT reproducible across nodes. A host call reads what this node
+// happens to hold, and wazero has no gas metering, so there is no
+// deterministic budget bounding the work a module may do.
+// - There is no process boundary. An engine escape lands in the node's own
+// address space, which is tolerable only because wazero is pure Go and
+// memory-safe. Keep the endpoint off public gateways.
+//
+// What does hold: node work is bounded (a concurrency semaphore, a wall-clock
+// watchdog and the host-call budgets in Limits), and a node-local failure is
+// never laundered into a program verdict — it surfaces as StatusHostError.
+package compute
+
+import "context"
+
+// Status classifies the outcome of a WASM execution.
+//
+// StatusOK, StatusTrap and StatusInvalidModule are program verdicts and are intended to be deterministic across nodes. StatusHostError
+// signals an infrastructure failure local to this node (spawn failure, watchdog
+// kill, IPC error) and must never be treated as a program result.
+type Status uint8
+
+const (
+ // StatusOK indicates the module ran to completion and produced output.
+ StatusOK Status = iota + 1
+ // StatusTrap indicates the module trapped (unreachable, out-of-bounds, non-zero exit, ...).
+ StatusTrap
+ // StatusInvalidModule indicates the bytes failed validation/compilation or import checks.
+ StatusInvalidModule
+ // StatusHostError indicates a non-deterministic infrastructure failure on this node.
+ StatusHostError
+)
+
+// String returns a stable, lower-kebab representation used in responses and headers.
+func (s Status) String() string {
+ switch s {
+ case StatusOK:
+ return "ok"
+ case StatusTrap:
+ return "trap"
+ case StatusInvalidModule:
+ return "invalid-module"
+ case StatusHostError:
+ return "host-error"
+ default:
+ return "unknown"
+ }
+}
+
+// Result is the outcome of executing a module.
+type Result struct {
+ Status Status
+ Output []byte
+ TrapMessage string
+ // Response is the HTTP metadata the guest set through swarm_response_status
+ // and swarm_response_header. It is populated only on StatusOK: a module that
+ // trapped has no say in how its failure is rendered, exactly as a module that
+ // trapped commits no upload.
+ //
+ // Note the deliberate asymmetry with Output, which a trapped module does keep
+ // (see classifyRunError). Partial output is evidence about what went wrong;
+ // partial response metadata would be an instruction the node should not follow.
+ Response ResponseMeta
+}
+
+// Header is one response header the guest set. Duplicates are kept in the order
+// they were set, because Link and Vary legitimately repeat.
+type Header struct {
+ Name string
+ Value string
+}
+
+// ResponseMeta is the HTTP status and headers a guest asked for. Its zero value
+// means the guest asked for nothing, which is the pre-existing behaviour and is
+// what Empty reports.
+type ResponseMeta struct {
+ // Status is the HTTP status code, or 0 when the guest did not set one.
+ Status int
+ // Headers are the accepted headers, in the order the guest set them.
+ Headers []Header
+}
+
+// Empty reports whether the guest set no response metadata at all. The API layer
+// uses it to decide whether a wildcard Accept still means "give me the envelope".
+func (r ResponseMeta) Empty() bool {
+ return r.Status == 0 && len(r.Headers) == 0
+}
+
+// Request describes a single execution: the module to run, the caller-supplied
+// input and the request metadata the module is allowed to observe.
+//
+// Every field but Host is derived from the incoming HTTP request. Host calls
+// read node-local state, so a module using them is not reproducible across
+// nodes; see the package documentation.
+type Request struct {
+ // Module is the WASM binary to execute.
+ Module []byte
+ // Method is the HTTP method the endpoint was called with. It is exposed to
+ // the guest as the REQUEST_METHOD environment variable, following CGI
+ // convention. An empty value means no method is exposed.
+ Method string
+ // Input is the request body, handed to the guest on stdin.
+ Input []byte
+ // Env carries the rest of the request metadata, CGI-style: PATH_INFO,
+ // QUERY_STRING, the allowlisted HTTP_* headers and so on.
+ //
+ // It is an ordered slice rather than a map because the guest can observe the
+ // order through environ_get, and Go's map iteration is random: a map would
+ // make a module's view of its own environment differ between two runs on the
+ // same node for no reason.
+ //
+ // REQUEST_METHOD comes from Method, not from here; a duplicate entry is
+ // ignored. The host environment is never inherited.
+ Env []EnvVar
+ // Limits bound the execution.
+ Limits Limits
+ // Host serves the calls the module makes back into the node. It is
+ // per-request: uploads it performs belong to one execution and no more.
+ //
+ // A nil Host leaves the swarm module uninstantiated, so a module importing
+ // it is StatusInvalidModule rather than trapping mid-run.
+ Host Host
+}
+
+// EnvVar is one environment variable the endpoint derived from the request.
+type EnvVar struct {
+ Name string
+ Value string
+}
+
+// Engine executes a single WASM module in isolation and returns its Result.
+//
+// A non-nil error is reserved for infrastructure failures (the engine could not
+// run the module at all); program-level outcomes such as traps or invalid
+// modules are reported through Result.Status with a nil error so callers can
+// treat them as deterministic verdicts.
+type Engine interface {
+ Execute(ctx context.Context, req Request) (Result, error)
+ Close() error
+}
diff --git a/pkg/compute/export_test.go b/pkg/compute/export_test.go
new file mode 100644
index 00000000000..23670dd788c
--- /dev/null
+++ b/pkg/compute/export_test.go
@@ -0,0 +1,71 @@
+// Copyright 2026 The Swarm Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package compute
+
+import (
+ "context"
+
+ "github.com/ethersphere/bee/v2/pkg/swarm"
+ "github.com/tetratelabs/wazero"
+)
+
+// SwarmResponseExports is the half of the import allowlist that needs no Host.
+var SwarmResponseExports = swarmResponseExports
+
+// SwarmHostExports is the half of the import allowlist that reaches the node.
+var SwarmHostExports = swarmHostExports
+
+// SwarmExports is the whole allowlist checkImports enforces when a Host is
+// available.
+func SwarmExports() map[string]struct{} {
+ all := make(map[string]struct{}, len(swarmResponseExports)+len(swarmHostExports))
+ for name := range swarmResponseExports {
+ all[name] = struct{}{}
+ }
+ for name := range swarmHostExports {
+ all[name] = struct{}{}
+ }
+ return all
+}
+
+// SwarmModuleExports instantiates the swarm host module and reports the names it
+// actually defines, so a test can hold it against the allowlists. hostAvailable
+// selects whether the data functions are registered, mirroring a node running
+// with and without node access.
+func SwarmModuleExports(ctx context.Context, hostAvailable bool) ([]string, error) {
+ r := wazero.NewRuntime(ctx)
+ defer r.Close(ctx)
+
+ hs := &hostState{}
+ if hostAvailable {
+ hs.host = noopHost{}
+ }
+ if err := buildSwarmModule(ctx, r, hs); err != nil {
+ return nil, err
+ }
+
+ var names []string
+ for name := range r.Module(swarmModuleName).ExportedFunctionDefinitions() {
+ names = append(names, name)
+ }
+ return names, nil
+}
+
+// noopHost stands in for a Host so buildSwarmModule registers the data half. It
+// is never called: the test only inspects the module's export list.
+type noopHost struct{}
+
+func (noopHost) BytesGet(context.Context, swarm.Address) ([]byte, error) {
+ return nil, ErrNotFound
+}
+func (noopHost) BytesPut(context.Context, []byte, []byte) (swarm.Address, error) {
+ return swarm.ZeroAddress, ErrDenied
+}
+func (noopHost) ChunkGet(context.Context, swarm.Address) ([]byte, error) {
+ return nil, ErrNotFound
+}
+func (noopHost) ChunkPut(context.Context, []byte, []byte) (swarm.Address, error) {
+ return swarm.ZeroAddress, ErrDenied
+}
diff --git a/pkg/compute/host.go b/pkg/compute/host.go
new file mode 100644
index 00000000000..733ea22f8e7
--- /dev/null
+++ b/pkg/compute/host.go
@@ -0,0 +1,588 @@
+// Copyright 2026 The Swarm Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package compute
+
+import (
+ "context"
+ "errors"
+ "strings"
+
+ "github.com/ethersphere/bee/v2/pkg/log"
+ "github.com/ethersphere/bee/v2/pkg/swarm"
+ "github.com/tetratelabs/wazero"
+ "github.com/tetratelabs/wazero/api"
+ "golang.org/x/net/http/httpguts"
+)
+
+// Host serves the calls a running module makes back into the node.
+//
+// A nil Host means the swarm module is not instantiated at all, so a module
+// importing it is rejected as invalid rather than trapping mid-run.
+//
+// Implementations report guest-caused outcomes with the ErrNotFound, ErrDenied
+// and ErrInvalid sentinels. Any other error is treated as a node-local failure:
+// it aborts the whole execution as StatusHostError and is never laundered into
+// a program verdict.
+type Host interface {
+ // BytesGet reassembles data of arbitrary length addressed by addr.
+ BytesGet(ctx context.Context, addr swarm.Address) ([]byte, error)
+ // BytesPut splits data of arbitrary length, stamps it with batchID and
+ // returns the root reference.
+ BytesPut(ctx context.Context, batchID, data []byte) (swarm.Address, error)
+ // ChunkGet retrieves a single chunk verbatim.
+ ChunkGet(ctx context.Context, addr swarm.Address) ([]byte, error)
+ // ChunkPut stores a single content-addressed chunk verbatim.
+ ChunkPut(ctx context.Context, batchID, data []byte) (swarm.Address, error)
+}
+
+// Sentinel errors a Host returns to describe a guest-caused failure. They are
+// the only errors a module can observe; everything else ends the run.
+var (
+ // ErrNotFound reports that the addressed data does not exist.
+ ErrNotFound = errors.New("compute: not found")
+ // ErrDenied reports that the node refused the call, e.g. an unusable
+ // postage batch or a second batch in one execution.
+ ErrDenied = errors.New("compute: denied")
+ // ErrInvalid reports malformed guest-supplied arguments.
+ ErrInvalid = errors.New("compute: invalid argument")
+ // ErrTooLarge reports a payload the execution's byte budget cannot cover.
+ // A Host uses it to refuse work before materialising it, which is why the
+ // budget alone is not enough: it is charged only on delivery.
+ ErrTooLarge = errors.New("compute: payload too large")
+)
+
+// swarmModuleName is the host module through which a guest reaches the node.
+const swarmModuleName = "swarm"
+
+// Guest-visible result codes returned by every swarm host function.
+const (
+ errnoOK uint32 = 0
+ errnoNotFound uint32 = 1
+ errnoDenied uint32 = 2
+ errnoBudgetExhausted uint32 = 3
+ errnoBufferTooSmall uint32 = 4
+ errnoInvalid uint32 = 5
+ errnoExecFailed uint32 = 6
+)
+
+// exitCodeHostAbort stops a module whose host call hit a node-local failure.
+// It is only a mechanism to unwind: hostState.err is the source of truth, so a
+// guest calling proc_exit with the same code is never mistaken for a host error.
+const exitCodeHostAbort uint32 = 0xBEE0
+
+// The swarm host module is defined in two halves, because they have different
+// preconditions. checkImports rejects anything outside the applicable set before
+// instantiation, so an unknown swarm import is a deterministic
+// StatusInvalidModule rather than a link trap.
+// TestSwarmExportsMatchBuilder keeps both in step with buildSwarmModule.
+var (
+ // swarmResponseExports shape the HTTP response. They cause no node work and
+ // are therefore always available, even when the node runs with node access
+ // switched off: setting a Content-Type is not a reason to need a Host.
+ swarmResponseExports = map[string]struct{}{
+ "swarm_response_status": {},
+ "swarm_response_header": {},
+ }
+ // swarmHostExports reach the node's data and exist only when a Host does.
+ swarmHostExports = map[string]struct{}{
+ "swarm_bytes_get": {},
+ "swarm_bytes_put": {},
+ "swarm_chunk_get": {},
+ "swarm_chunk_put": {},
+ "swarm_execute": {},
+ }
+)
+
+// Response headers a guest may not set. Everything else is allowed: a response
+// header carries only what the guest already knows, so an allowlist here would be
+// endless friction (Cache-Control, ETag, Location, Link, Vary, ...). The request
+// direction is the opposite — see the allowlist in pkg/api, which guards the
+// operator's secrets.
+var (
+ // A guest must not forge the node's own protocol namespace, and must not
+ // touch CORS: the node sets Access-Control-Allow-Credentials, so a guest
+ // setting Allow-Origin would be a genuine cross-origin credential leak.
+ deniedResponseHeaderPrefixes = []string{"swarm-wasm-", "access-control-"}
+
+ deniedResponseHeaders = map[string]struct{}{
+ // Hop-by-hop and framing: Go owns the wire format.
+ "connection": {},
+ "keep-alive": {},
+ "proxy-authenticate": {},
+ "proxy-authorization": {},
+ "te": {},
+ "trailer": {},
+ "transfer-encoding": {},
+ "upgrade": {},
+ "content-length": {},
+ "host": {},
+ // Every module shares the node's origin with the node's own authenticated
+ // API, so a guest-set cookie is session fixation across all of them.
+ // Revisit only behind a per-module origin.
+ "set-cookie": {},
+ // Origin-wide and persistent: a guest must not be able to reconfigure or
+ // brick the operator's origin.
+ "strict-transport-security": {},
+ "public-key-pins": {},
+ "clear-site-data": {},
+ }
+)
+
+// DeniedResponseHeader reports whether a guest is forbidden from setting name.
+// It is exported so the API layer can re-check before applying, keeping an engine
+// bug from becoming a leak.
+func DeniedResponseHeader(name string) bool {
+ lower := strings.ToLower(name)
+ for _, prefix := range deniedResponseHeaderPrefixes {
+ if strings.HasPrefix(lower, prefix) {
+ return true
+ }
+ }
+ _, denied := deniedResponseHeaders[lower]
+ return denied
+}
+
+// responseState accumulates what the guest asked for through swarm_response_*.
+// It belongs to the outermost execution alone; see hostState.resp.
+type responseState struct {
+ status int
+ headers []Header
+ bytes uint32
+ maxHeaders uint32
+ maxBytes uint32
+}
+
+func newResponseState(l Limits) *responseState {
+ return &responseState{
+ maxHeaders: l.ResponseHeaders(),
+ maxBytes: l.ResponseHeaderBytes(),
+ }
+}
+
+// add records an accepted header, charging it against the two caps.
+func (r *responseState) add(name, value string) uint32 {
+ if uint32(len(r.headers)) >= r.maxHeaders {
+ return errnoBudgetExhausted
+ }
+ // Subtract rather than add: bytes never exceeds maxBytes, so this cannot
+ // overflow the way bytes+size could for a large configured maximum.
+ size := uint32(len(name) + len(value))
+ if size > r.maxBytes-r.bytes {
+ return errnoBudgetExhausted
+ }
+ r.bytes += size
+ r.headers = append(r.headers, Header{Name: name, Value: value})
+ return errnoOK
+}
+
+// snapshot renders what the guest set. A nil receiver is the no-metadata case.
+func (r *responseState) snapshot() ResponseMeta {
+ if r == nil {
+ return ResponseMeta{}
+ }
+ return ResponseMeta{Status: r.status, Headers: r.headers}
+}
+
+// validResponseHeaderValue accepts HTAB and printable ASCII only.
+//
+// This is deliberately stricter than RFC 9110, which also permits obs-text
+// (0x80-0xFF): those bytes have no agreed encoding in a header value and are a
+// smuggling surface. Rejecting control characters here subsumes CR/LF injection —
+// Go's net/http would silently strip newlines on write, and a silent mangling is
+// worse for a guest than a result code it can branch on.
+func validResponseHeaderValue(value string) bool {
+ for i := 0; i < len(value); i++ {
+ if c := value[i]; c != '\t' && (c < 0x20 || c > 0x7e) {
+ return false
+ }
+ }
+ return true
+}
+
+// budget bounds the node work one execution tree may cause. It is shared by
+// pointer across nested executions so a module cannot multiply its allowance by
+// recursing.
+type budget struct {
+ calls uint32
+ bytes uint64
+}
+
+func newBudget(l Limits) *budget {
+ return &budget{calls: l.HostCalls(), bytes: l.HostBytes()}
+}
+
+// useCall charges one host call, reporting whether the budget allowed it.
+func (b *budget) useCall() bool {
+ if b.calls == 0 {
+ return false
+ }
+ b.calls--
+ return true
+}
+
+// useBytes charges n bytes, reporting whether the budget allowed it. The charge
+// is all-or-nothing so a rejected call consumes nothing.
+func (b *budget) useBytes(n uint64) bool {
+ if n > b.bytes {
+ return false
+ }
+ b.bytes -= n
+ return true
+}
+
+// nestedFunc runs a module fetched by swarm_execute, re-entering the engine.
+type nestedFunc func(ctx context.Context, module, input []byte) (Result, error)
+
+// hostState backs the swarm host module for a single execution tree. A fresh
+// one is built per execution, so nothing is shared between untrusted programs.
+type hostState struct {
+ host Host
+ budget *budget
+ nested nestedFunc
+ // depth is the current nesting level; 0 is the outermost execution.
+ depth uint32
+ // maxDepth bounds the number of execution levels, the outermost included.
+ maxDepth uint32
+ logger log.Logger
+ // resp accumulates the guest's HTTP response metadata. It is non-nil only at
+ // depth 0: a module reached through swarm_execute is a library call, not an
+ // HTTP request, and must not be able to rewrite its caller's response. A nil
+ // resp is what makes the response calls return DENIED when nested.
+ resp *responseState
+ // err records a node-local failure. When set, the run is aborted and its
+ // verdict is StatusHostError regardless of how wazero reports the unwind.
+ err error
+}
+
+// abort records a node-local failure and stops the module. The errno it returns
+// is never observed: the guest is being torn down.
+func (h *hostState) abort(ctx context.Context, mod api.Module, err error) uint32 {
+ if h.err == nil {
+ h.err = err
+ }
+ h.logger.Debug("host call failed", "error", err)
+ _ = mod.CloseWithExitCode(ctx, exitCodeHostAbort)
+ return errnoInvalid
+}
+
+// classify maps a Host error to a guest-visible errno. The bool is false when
+// the error is node-local and the run must be aborted instead.
+func classifyHostErr(err error) (uint32, bool) {
+ switch {
+ case errors.Is(err, ErrNotFound):
+ return errnoNotFound, true
+ case errors.Is(err, ErrDenied):
+ return errnoDenied, true
+ case errors.Is(err, ErrInvalid):
+ return errnoInvalid, true
+ case errors.Is(err, ErrTooLarge):
+ return errnoBudgetExhausted, true
+ default:
+ return 0, false
+ }
+}
+
+// buildSwarmModule instantiates the swarm host module against the runtime.
+//
+// The response functions are always defined; the data functions only when a Host
+// is present, which is what makes an unavailable node a deterministic
+// invalid-module for a data import while still letting any module set a
+// Content-Type.
+func buildSwarmModule(ctx context.Context, r wazero.Runtime, h *hostState) error {
+ b := r.NewHostModuleBuilder(swarmModuleName).
+ NewFunctionBuilder().
+ WithFunc(h.responseStatus).
+ WithParameterNames("code").
+ Export("swarm_response_status").
+ NewFunctionBuilder().
+ WithFunc(h.responseHeader).
+ WithParameterNames("name_ptr", "name_len", "val_ptr", "val_len").
+ Export("swarm_response_header")
+
+ if h.host == nil {
+ _, err := b.Instantiate(ctx)
+ return err
+ }
+
+ _, err := b.
+ NewFunctionBuilder().
+ WithFunc(h.bytesGet).
+ WithParameterNames("addr_ptr", "buf_ptr", "buf_len", "out_len_ptr").
+ Export("swarm_bytes_get").
+ NewFunctionBuilder().
+ WithFunc(h.bytesPut).
+ WithParameterNames("batch_ptr", "data_ptr", "data_len", "out_addr_ptr").
+ Export("swarm_bytes_put").
+ NewFunctionBuilder().
+ WithFunc(h.chunkGet).
+ WithParameterNames("addr_ptr", "buf_ptr", "buf_len", "out_len_ptr").
+ Export("swarm_chunk_get").
+ NewFunctionBuilder().
+ WithFunc(h.chunkPut).
+ WithParameterNames("batch_ptr", "data_ptr", "data_len", "out_addr_ptr").
+ Export("swarm_chunk_put").
+ NewFunctionBuilder().
+ WithFunc(h.execute).
+ WithParameterNames("addr_ptr", "input_ptr", "input_len", "buf_ptr", "buf_len", "out_len_ptr").
+ Export("swarm_execute").
+ Instantiate(ctx)
+ return err
+}
+
+// responseStatus sets the HTTP status the node will answer with.
+//
+// The last call wins. 1xx is refused because writing an informational status
+// through an http.ResponseWriter desynchronises the connection; 5xx is allowed,
+// because a module must be able to report its own failure and Swarm-Wasm-Status
+// already distinguishes the guest's 500 (ok) from the node's (host-error).
+func (h *hostState) responseStatus(_ context.Context, _ api.Module, code uint32) uint32 {
+ if h.resp == nil {
+ return errnoDenied
+ }
+ if code < 200 || code > 599 {
+ return errnoInvalid
+ }
+ h.resp.status = int(code)
+ return errnoOK
+}
+
+// responseHeader appends a header to the response.
+//
+// The checks run in a fixed order and the SDK harness mirrors it exactly, so a
+// module gets the same result code locally and on a node. Lengths are validated
+// before any guest memory is read, so an absurd val_len never allocates, and a
+// rejected call charges nothing.
+func (h *hostState) responseHeader(_ context.Context, mod api.Module, namePtr, nameLen, valPtr, valLen uint32) uint32 {
+ if h.resp == nil {
+ return errnoDenied
+ }
+ mem := mod.Memory()
+ if mem == nil {
+ return errnoInvalid
+ }
+ if nameLen == 0 || nameLen > MaxResponseHeaderNameLen || valLen > MaxResponseHeaderValueLen {
+ return errnoInvalid
+ }
+
+ nameBytes, ok := mem.Read(namePtr, nameLen)
+ if !ok {
+ return errnoInvalid
+ }
+ valueBytes, ok := mem.Read(valPtr, valLen)
+ if !ok {
+ return errnoInvalid
+ }
+ // Memory.Read aliases guest memory; string() copies.
+ name, value := string(nameBytes), string(valueBytes)
+
+ if !httpguts.ValidHeaderFieldName(name) || !validResponseHeaderValue(value) {
+ return errnoInvalid
+ }
+ if DeniedResponseHeader(name) {
+ return errnoDenied
+ }
+ return h.resp.add(name, value)
+}
+
+func (h *hostState) bytesGet(ctx context.Context, mod api.Module, addrPtr, bufPtr, bufLen, outLenPtr uint32) uint32 {
+ return h.get(ctx, mod, addrPtr, bufPtr, bufLen, outLenPtr, h.host.BytesGet)
+}
+
+func (h *hostState) chunkGet(ctx context.Context, mod api.Module, addrPtr, bufPtr, bufLen, outLenPtr uint32) uint32 {
+ return h.get(ctx, mod, addrPtr, bufPtr, bufLen, outLenPtr, h.host.ChunkGet)
+}
+
+func (h *hostState) bytesPut(ctx context.Context, mod api.Module, batchPtr, dataPtr, dataLen, outAddrPtr uint32) uint32 {
+ return h.put(ctx, mod, batchPtr, dataPtr, dataLen, outAddrPtr, 0, h.host.BytesPut)
+}
+
+func (h *hostState) chunkPut(ctx context.Context, mod api.Module, batchPtr, dataPtr, dataLen, outAddrPtr uint32) uint32 {
+ return h.put(ctx, mod, batchPtr, dataPtr, dataLen, outAddrPtr, swarm.ChunkWithSpanSize, h.host.ChunkPut)
+}
+
+// get is the shared body of the address-in, data-out calls.
+//
+// A call is charged whether or not the data fits the guest buffer; bytes are
+// charged only when they are actually delivered, so the probe-then-fetch
+// pattern (buf_len 0, read out_len, retry) costs two calls but pays for the
+// payload once.
+func (h *hostState) get(
+ ctx context.Context,
+ mod api.Module,
+ addrPtr, bufPtr, bufLen, outLenPtr uint32,
+ fn func(context.Context, swarm.Address) ([]byte, error),
+) uint32 {
+ if !h.budget.useCall() {
+ return errnoBudgetExhausted
+ }
+
+ mem := mod.Memory()
+ if mem == nil {
+ return errnoInvalid
+ }
+ addr, ok := readAddress(mem, addrPtr)
+ if !ok {
+ return errnoInvalid
+ }
+
+ data, err := fn(ctx, addr)
+ if err != nil {
+ if code, guest := classifyHostErr(err); guest {
+ return code
+ }
+ return h.abort(ctx, mod, err)
+ }
+
+ return h.deliver(ctx, mod, bufPtr, bufLen, outLenPtr, data)
+}
+
+// deliver writes data into the guest buffer, always reporting the required
+// length through outLenPtr so a too-small buffer can be retried.
+func (h *hostState) deliver(ctx context.Context, mod api.Module, bufPtr, bufLen, outLenPtr uint32, data []byte) uint32 {
+ mem := mod.Memory()
+ if mem == nil {
+ return errnoInvalid
+ }
+ if !mem.WriteUint32Le(outLenPtr, uint32(len(data))) {
+ return errnoInvalid
+ }
+ if uint64(len(data)) > uint64(bufLen) {
+ return errnoBufferTooSmall
+ }
+ if !h.budget.useBytes(uint64(len(data))) {
+ return errnoBudgetExhausted
+ }
+ if !mem.Write(bufPtr, data) {
+ return errnoInvalid
+ }
+ return errnoOK
+}
+
+// put is the shared body of the data-in, address-out calls. maxLen caps the
+// accepted payload; 0 means only the byte budget applies.
+func (h *hostState) put(
+ ctx context.Context,
+ mod api.Module,
+ batchPtr, dataPtr, dataLen, outAddrPtr uint32,
+ maxLen uint32,
+ fn func(context.Context, []byte, []byte) (swarm.Address, error),
+) uint32 {
+ if !h.budget.useCall() {
+ return errnoBudgetExhausted
+ }
+ if maxLen > 0 && dataLen > maxLen {
+ return errnoInvalid
+ }
+ // Charge the declared length before doing any work, so an oversized put is
+ // refused without the node ever chunking it.
+ if !h.budget.useBytes(uint64(dataLen)) {
+ return errnoBudgetExhausted
+ }
+
+ mem := mod.Memory()
+ if mem == nil {
+ return errnoInvalid
+ }
+ batchID, ok := mem.Read(batchPtr, swarm.HashSize)
+ if !ok {
+ return errnoInvalid
+ }
+ data, ok := mem.Read(dataPtr, dataLen)
+ if !ok {
+ return errnoInvalid
+ }
+
+ // Memory.Read aliases the guest's memory; the storer keeps what it is given,
+ // so hand it a copy the guest cannot mutate underneath it.
+ addr, err := fn(ctx, bytesClone(batchID), bytesClone(data))
+ if err != nil {
+ if code, guest := classifyHostErr(err); guest {
+ return code
+ }
+ return h.abort(ctx, mod, err)
+ }
+
+ if !mem.Write(outAddrPtr, addr.Bytes()) {
+ return errnoInvalid
+ }
+ return errnoOK
+}
+
+// execute fetches a module from Swarm and runs it as a nested execution sharing
+// this tree's budget.
+func (h *hostState) execute(ctx context.Context, mod api.Module, addrPtr, inputPtr, inputLen, bufPtr, bufLen, outLenPtr uint32) uint32 {
+ if !h.budget.useCall() {
+ return errnoBudgetExhausted
+ }
+ // maxDepth counts execution levels, the outermost included, so a limit of 1
+ // permits no nesting at all.
+ if h.depth+1 >= h.maxDepth {
+ return errnoBudgetExhausted
+ }
+ if !h.budget.useBytes(uint64(inputLen)) {
+ return errnoBudgetExhausted
+ }
+
+ mem := mod.Memory()
+ if mem == nil {
+ return errnoInvalid
+ }
+ addr, ok := readAddress(mem, addrPtr)
+ if !ok {
+ return errnoInvalid
+ }
+ input, ok := mem.Read(inputPtr, inputLen)
+ if !ok {
+ return errnoInvalid
+ }
+ input = bytesClone(input)
+
+ module, err := h.host.BytesGet(ctx, addr)
+ if err != nil {
+ if code, guest := classifyHostErr(err); guest {
+ return code
+ }
+ return h.abort(ctx, mod, err)
+ }
+ if !h.budget.useBytes(uint64(len(module))) {
+ return errnoBudgetExhausted
+ }
+
+ res, err := h.nested(ctx, module, input)
+ if err != nil {
+ return h.abort(ctx, mod, err)
+ }
+ switch res.Status {
+ case StatusOK:
+ return h.deliver(ctx, mod, bufPtr, bufLen, outLenPtr, res.Output)
+ case StatusHostError:
+ // A node-local failure inside the child is a node-local failure here.
+ return h.abort(ctx, mod, errors.New("nested execution failed"))
+ default:
+ // The child trapped or was invalid. That is a verdict
+ // on the child, which the caller may handle.
+ return errnoExecFailed
+ }
+}
+
+// readAddress reads a fixed-width Swarm address out of guest memory.
+//
+// Callers check mod.Memory() for nil first: a module may import the swarm
+// functions and declare no memory at all (nothing in checkImports requires one),
+// and api.Memory is a typed-nil interface in that case.
+func readAddress(mem api.Memory, ptr uint32) (swarm.Address, bool) {
+ b, ok := mem.Read(ptr, swarm.HashSize)
+ if !ok {
+ return swarm.ZeroAddress, false
+ }
+ return swarm.NewAddress(bytesClone(b)), true
+}
+
+// bytesClone copies a slice aliasing guest memory.
+func bytesClone(b []byte) []byte {
+ out := make([]byte, len(b))
+ copy(out, b)
+ return out
+}
diff --git a/pkg/compute/host_test.go b/pkg/compute/host_test.go
new file mode 100644
index 00000000000..a446bf6d502
--- /dev/null
+++ b/pkg/compute/host_test.go
@@ -0,0 +1,587 @@
+// Copyright 2026 The Swarm Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package compute_test
+
+import (
+ "bytes"
+ "context"
+ "encoding/binary"
+ "errors"
+ "sort"
+ "sync"
+ "testing"
+
+ "github.com/ethersphere/bee/v2/pkg/compute"
+ "github.com/ethersphere/bee/v2/pkg/swarm"
+)
+
+// Guest-visible result codes, mirrored from the ABI so a test asserts the
+// number a module actually observes.
+const (
+ errnoOK = 0
+ errnoNotFound = 1
+ errnoDenied = 2
+ errnoBudgetExhausted = 3
+ errnoBufferTooSmall = 4
+ errnoInvalid = 5
+ errnoExecFailed = 6
+)
+
+// mockHost serves canned data and records what the guest asked for.
+type mockHost struct {
+ data map[string][]byte
+ chunks map[string][]byte
+
+ // err, when set, is returned by every call, standing in for a node-local
+ // failure that must never become a program verdict.
+ err error
+ // denyPut makes the puts report a refused batch.
+ denyPut bool
+
+ bytesGets int
+ puts [][]byte
+}
+
+func newMockHost() *mockHost {
+ return &mockHost{data: map[string][]byte{}, chunks: map[string][]byte{}}
+}
+
+// addData stores payload under a synthetic address derived from seed.
+func (m *mockHost) addData(seed byte, payload []byte) swarm.Address {
+ addr := addressOf(seed)
+ m.data[addr.String()] = payload
+ return addr
+}
+
+func (m *mockHost) BytesGet(_ context.Context, addr swarm.Address) ([]byte, error) {
+ m.bytesGets++
+ if m.err != nil {
+ return nil, m.err
+ }
+ payload, ok := m.data[addr.String()]
+ if !ok {
+ return nil, compute.ErrNotFound
+ }
+ return payload, nil
+}
+
+func (m *mockHost) BytesPut(_ context.Context, batchID, data []byte) (swarm.Address, error) {
+ if m.err != nil {
+ return swarm.ZeroAddress, m.err
+ }
+ if m.denyPut {
+ return swarm.ZeroAddress, compute.ErrDenied
+ }
+ m.puts = append(m.puts, data)
+ addr := addressOf(byte(len(m.data) + 1))
+ m.data[addr.String()] = data
+ return addr, nil
+}
+
+func (m *mockHost) ChunkGet(_ context.Context, addr swarm.Address) ([]byte, error) {
+ if m.err != nil {
+ return nil, m.err
+ }
+ chunk, ok := m.chunks[addr.String()]
+ if !ok {
+ return nil, compute.ErrNotFound
+ }
+ return chunk, nil
+}
+
+func (m *mockHost) ChunkPut(_ context.Context, batchID, data []byte) (swarm.Address, error) {
+ if m.err != nil {
+ return swarm.ZeroAddress, m.err
+ }
+ if m.denyPut {
+ return swarm.ZeroAddress, compute.ErrDenied
+ }
+ addr := addressOf(byte(len(m.chunks) + 100))
+ m.chunks[addr.String()] = data
+ return addr, nil
+}
+
+// addressOf builds a distinct, readable 32-byte address from a single byte.
+func addressOf(seed byte) swarm.Address {
+ b := make([]byte, swarm.HashSize)
+ for i := range b {
+ b[i] = seed
+ }
+ return swarm.NewAddress(b)
+}
+
+// u32 encodes a little-endian uint32 the way the fixtures read and write them.
+func u32(v uint32) []byte {
+ b := make([]byte, 4)
+ binary.LittleEndian.PutUint32(b, v)
+ return b
+}
+
+// runHost executes a fixture against a host and returns its raw stdout.
+func runHost(t *testing.T, host compute.Host, module string, input []byte, limits compute.Limits) compute.Result {
+ t.Helper()
+
+ s := newService(t, compute.Options{})
+ res, err := s.Execute(t.Context(), compute.Request{
+ Module: loadModule(t, module),
+ Input: input,
+ Limits: limits,
+ Host: host,
+ })
+ if err != nil {
+ t.Fatalf("execute: %v", err)
+ }
+ return res
+}
+
+// splitOutput cuts the leading fixed-width fields off a fixture's stdout.
+func splitOutput(t *testing.T, out []byte, fields int) ([]uint32, []byte) {
+ t.Helper()
+
+ if len(out) < fields*4 {
+ t.Fatalf("output too short: got %d bytes, want at least %d", len(out), fields*4)
+ }
+ values := make([]uint32, fields)
+ for i := range values {
+ values[i] = binary.LittleEndian.Uint32(out[i*4:])
+ }
+ return values, out[fields*4:]
+}
+
+func TestHostBytesGet(t *testing.T) {
+ t.Parallel()
+
+ payload := []byte("data reached the guest")
+
+ for _, tc := range []struct {
+ name string
+ addr swarm.Address
+ bufLen uint32
+ wantErrno uint32
+ wantLen uint32
+ wantData []byte
+ }{
+ {
+ name: "delivers the payload",
+ addr: addressOf(1),
+ bufLen: 4096,
+ wantErrno: errnoOK,
+ wantLen: uint32(len(payload)),
+ wantData: payload,
+ },
+ {
+ name: "missing address",
+ addr: addressOf(9),
+ bufLen: 4096,
+ wantErrno: errnoNotFound,
+ },
+ {
+ name: "buffer too small reports the required length",
+ addr: addressOf(1),
+ // The probe half of the two-call pattern: no buffer at all, so the
+ // guest learns how much to allocate before asking again.
+ bufLen: 0,
+ wantErrno: errnoBufferTooSmall,
+ wantLen: uint32(len(payload)),
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ host := newMockHost()
+ host.addData(1, payload)
+
+ res := runHost(t, host, "hostbytesget", append(tc.addr.Bytes(), u32(tc.bufLen)...), compute.Limits{})
+ if res.Status != compute.StatusOK {
+ t.Fatalf("status: got %v, want %v (%s)", res.Status, compute.StatusOK, res.TrapMessage)
+ }
+
+ fields, data := splitOutput(t, res.Output, 2)
+ if fields[0] != tc.wantErrno {
+ t.Errorf("errno: got %d, want %d", fields[0], tc.wantErrno)
+ }
+ if fields[1] != tc.wantLen {
+ t.Errorf("required length: got %d, want %d", fields[1], tc.wantLen)
+ }
+ if !bytes.Equal(data, tc.wantData) {
+ t.Errorf("payload: got %q, want %q", data, tc.wantData)
+ }
+ })
+ }
+}
+
+func TestHostBytesPut(t *testing.T) {
+ t.Parallel()
+
+ payload := []byte("uploaded by the guest")
+ batch := bytes.Repeat([]byte{7}, swarm.HashSize)
+
+ t.Run("stores the payload", func(t *testing.T) {
+ t.Parallel()
+
+ host := newMockHost()
+ res := runHost(t, host, "hostbytesput", append(batch, payload...), compute.Limits{})
+ if res.Status != compute.StatusOK {
+ t.Fatalf("status: got %v, want %v (%s)", res.Status, compute.StatusOK, res.TrapMessage)
+ }
+
+ fields, ref := splitOutput(t, res.Output, 1)
+ if fields[0] != errnoOK {
+ t.Fatalf("errno: got %d, want %d", fields[0], errnoOK)
+ }
+ if len(ref) != swarm.HashSize {
+ t.Fatalf("reference length: got %d, want %d", len(ref), swarm.HashSize)
+ }
+ if len(host.puts) != 1 || !bytes.Equal(host.puts[0], payload) {
+ t.Fatalf("stored payload: got %q", host.puts)
+ }
+ // The reference must resolve to what was stored.
+ if got := host.data[swarm.NewAddress(ref).String()]; !bytes.Equal(got, payload) {
+ t.Errorf("reference resolves to %q, want %q", got, payload)
+ }
+ })
+
+ t.Run("refused batch is a guest-visible verdict", func(t *testing.T) {
+ t.Parallel()
+
+ host := newMockHost()
+ host.denyPut = true
+
+ res := runHost(t, host, "hostbytesput", append(batch, payload...), compute.Limits{})
+ if res.Status != compute.StatusOK {
+ t.Fatalf("status: got %v, want %v", res.Status, compute.StatusOK)
+ }
+ fields, _ := splitOutput(t, res.Output, 1)
+ if fields[0] != errnoDenied {
+ t.Errorf("errno: got %d, want %d", fields[0], errnoDenied)
+ }
+ })
+}
+
+func TestHostChunkRoundTrip(t *testing.T) {
+ t.Parallel()
+
+ batch := bytes.Repeat([]byte{7}, swarm.HashSize)
+ // An 8-byte span followed by the chunk payload, as the chunk API expects.
+ chunk := append(u32(11), u32(0)...)
+ chunk = append(chunk, []byte("chunk bytes")...)
+
+ host := newMockHost()
+ res := runHost(t, host, "hostchunk", append(batch, chunk...), compute.Limits{})
+ if res.Status != compute.StatusOK {
+ t.Fatalf("status: got %v, want %v (%s)", res.Status, compute.StatusOK, res.TrapMessage)
+ }
+
+ fields, data := splitOutput(t, res.Output, 2)
+ if fields[0] != errnoOK {
+ t.Fatalf("put errno: got %d, want %d", fields[0], errnoOK)
+ }
+ if fields[1] != errnoOK {
+ t.Fatalf("get errno: got %d, want %d", fields[1], errnoOK)
+ }
+ if !bytes.Equal(data, chunk) {
+ t.Errorf("retrieved chunk: got %q, want %q", data, chunk)
+ }
+}
+
+func TestHostCallBudget(t *testing.T) {
+ t.Parallel()
+
+ host := newMockHost()
+ addr := host.addData(1, []byte("x"))
+
+ const allowed = 3
+ res := runHost(t, host, "hostcalls", addr.Bytes(), compute.Limits{MaxHostCalls: allowed})
+ if res.Status != compute.StatusOK {
+ t.Fatalf("status: got %v, want %v (%s)", res.Status, compute.StatusOK, res.TrapMessage)
+ }
+
+ fields, _ := splitOutput(t, res.Output, 2)
+ if fields[0] != allowed {
+ t.Errorf("successful calls: got %d, want %d", fields[0], allowed)
+ }
+ if fields[1] != errnoBudgetExhausted {
+ t.Errorf("errno: got %d, want %d", fields[1], errnoBudgetExhausted)
+ }
+ // The host must not have been asked to do work beyond the budget.
+ if host.bytesGets != allowed {
+ t.Errorf("host calls reaching the node: got %d, want %d", host.bytesGets, allowed)
+ }
+}
+
+func TestHostByteBudget(t *testing.T) {
+ t.Parallel()
+
+ payload := bytes.Repeat([]byte("a"), 512)
+ host := newMockHost()
+ addr := host.addData(1, payload)
+
+ // Enough for one delivery, not two.
+ res := runHost(t, host, "hostcalls", addr.Bytes(), compute.Limits{MaxHostBytes: 600})
+ if res.Status != compute.StatusOK {
+ t.Fatalf("status: got %v, want %v (%s)", res.Status, compute.StatusOK, res.TrapMessage)
+ }
+
+ fields, _ := splitOutput(t, res.Output, 2)
+ if fields[0] != 1 {
+ t.Errorf("successful calls: got %d, want 1", fields[0])
+ }
+ if fields[1] != errnoBudgetExhausted {
+ t.Errorf("errno: got %d, want %d", fields[1], errnoBudgetExhausted)
+ }
+}
+
+func TestHostBadPointer(t *testing.T) {
+ t.Parallel()
+
+ // A pointer outside linear memory is the guest's mistake: it must come back
+ // as a result code, not tear the module down.
+ res := runHost(t, newMockHost(), "hostbadptr", nil, compute.Limits{})
+ if res.Status != compute.StatusOK {
+ t.Fatalf("status: got %v, want %v (%s)", res.Status, compute.StatusOK, res.TrapMessage)
+ }
+
+ fields, _ := splitOutput(t, res.Output, 1)
+ if fields[0] != errnoInvalid {
+ t.Errorf("errno: got %d, want %d", fields[0], errnoInvalid)
+ }
+}
+
+func TestHostErrorIsNeverATrap(t *testing.T) {
+ t.Parallel()
+
+ // The central invariant: a node-local failure ends the run as a host error
+ // with a non-nil error. Reporting it as a trap would tell every caller the
+ // program was at fault, which is a verdict this node has no right to make.
+ host := newMockHost()
+ host.addData(1, []byte("unreachable"))
+ host.err = errors.New("storer exploded")
+
+ s := newService(t, compute.Options{})
+ res, err := s.Execute(t.Context(), compute.Request{
+ Module: loadModule(t, "hostbytesget"),
+ Input: append(addressOf(1).Bytes(), u32(4096)...),
+ Host: host,
+ })
+ if err == nil {
+ t.Fatal("expected a non-nil error for a node-local failure")
+ }
+ if res.Status != compute.StatusHostError {
+ t.Errorf("status: got %v, want %v", res.Status, compute.StatusHostError)
+ }
+}
+
+func TestHostUnavailable(t *testing.T) {
+ t.Parallel()
+
+ // With no Host the swarm module is not instantiated, so a module importing
+ // it is rejected up front rather than trapping mid-run.
+ s := newService(t, compute.Options{})
+ res, err := s.Execute(t.Context(), compute.Request{
+ Module: loadModule(t, "hostbytesget"),
+ Input: append(addressOf(1).Bytes(), u32(4096)...),
+ })
+ if err != nil {
+ t.Fatalf("execute: %v", err)
+ }
+ if res.Status != compute.StatusInvalidModule {
+ t.Errorf("status: got %v, want %v", res.Status, compute.StatusInvalidModule)
+ }
+}
+
+func TestHostUnknownImport(t *testing.T) {
+ t.Parallel()
+
+ res := runHost(t, newMockHost(), "hostunknown", nil, compute.Limits{})
+ if res.Status != compute.StatusInvalidModule {
+ t.Errorf("status: got %v, want %v", res.Status, compute.StatusInvalidModule)
+ }
+}
+
+func TestSwarmExportsMatchBuilder(t *testing.T) {
+ t.Parallel()
+
+ // checkImports rejects swarm imports outside the allowlist before the
+ // module is instantiated. If the allowlist and the builder drift apart, a
+ // real function becomes unreachable or a missing one becomes a link trap.
+ //
+ // The module is built in two halves: the response functions always, the data
+ // functions only when a Host is present. Both shapes are checked, because a
+ // drift in either is the same class of bug.
+ for _, tc := range []struct {
+ name string
+ hostAvailable bool
+ allowed map[string]struct{}
+ }{
+ {"with node access", true, compute.SwarmExports()},
+ {"without node access", false, compute.SwarmResponseExports},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ defined, err := compute.SwarmModuleExports(t.Context(), tc.hostAvailable)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ allowed := make([]string, 0, len(tc.allowed))
+ for name := range tc.allowed {
+ allowed = append(allowed, name)
+ }
+ sort.Strings(allowed)
+ sort.Strings(defined)
+
+ if len(allowed) != len(defined) {
+ t.Fatalf("allowlist %v, host module defines %v", allowed, defined)
+ }
+ for i := range allowed {
+ if allowed[i] != defined[i] {
+ t.Errorf("allowlist %v, host module defines %v", allowed, defined)
+ break
+ }
+ }
+ })
+ }
+}
+
+func TestHostNestedExecute(t *testing.T) {
+ t.Parallel()
+
+ t.Run("output of the nested module is forwarded", func(t *testing.T) {
+ t.Parallel()
+
+ host := newMockHost()
+ addr := host.addData(2, loadModule(t, "echo"))
+
+ res := runHost(t, host, "hostnested", append(addr.Bytes(), []byte("nested input")...), compute.Limits{})
+ if res.Status != compute.StatusOK {
+ t.Fatalf("status: got %v, want %v (%s)", res.Status, compute.StatusOK, res.TrapMessage)
+ }
+
+ fields, data := splitOutput(t, res.Output, 2)
+ if fields[0] != errnoOK {
+ t.Fatalf("errno: got %d, want %d", fields[0], errnoOK)
+ }
+ if !bytes.Equal(data, []byte("nested input")) {
+ t.Errorf("nested output: got %q, want %q", data, "nested input")
+ }
+ })
+
+ t.Run("depth limit refuses nesting", func(t *testing.T) {
+ t.Parallel()
+
+ host := newMockHost()
+ addr := host.addData(2, loadModule(t, "echo"))
+
+ // One level means the outermost execution and nothing below it.
+ res := runHost(t, host, "hostnested", append(addr.Bytes(), []byte("x")...), compute.Limits{MaxDepth: 1})
+ if res.Status != compute.StatusOK {
+ t.Fatalf("status: got %v, want %v", res.Status, compute.StatusOK)
+ }
+ fields, _ := splitOutput(t, res.Output, 2)
+ if fields[0] != errnoBudgetExhausted {
+ t.Errorf("errno: got %d, want %d", fields[0], errnoBudgetExhausted)
+ }
+ })
+
+ t.Run("a nested trap is a verdict, not a host failure", func(t *testing.T) {
+ t.Parallel()
+
+ host := newMockHost()
+ addr := host.addData(2, loadModule(t, "trap"))
+
+ res := runHost(t, host, "hostnested", addr.Bytes(), compute.Limits{})
+ if res.Status != compute.StatusOK {
+ t.Fatalf("status: got %v, want %v (%s)", res.Status, compute.StatusOK, res.TrapMessage)
+ }
+ fields, _ := splitOutput(t, res.Output, 2)
+ if fields[0] != errnoExecFailed {
+ t.Errorf("errno: got %d, want %d", fields[0], errnoExecFailed)
+ }
+ })
+
+ t.Run("the call budget is shared with the nested module", func(t *testing.T) {
+ t.Parallel()
+
+ host := newMockHost()
+ module := host.addData(2, loadModule(t, "hostcalls"))
+ payload := host.addData(3, []byte("y"))
+
+ // Three calls: swarm_execute takes one, leaving the nested module two
+ // before it is cut off. Were the budget rebuilt per execution the
+ // nested module would get all three.
+ res := runHost(t, host, "hostnested", append(module.Bytes(), payload.Bytes()...), compute.Limits{MaxHostCalls: 3})
+ if res.Status != compute.StatusOK {
+ t.Fatalf("status: got %v, want %v (%s)", res.Status, compute.StatusOK, res.TrapMessage)
+ }
+
+ fields, data := splitOutput(t, res.Output, 2)
+ if fields[0] != errnoOK {
+ t.Fatalf("errno: got %d, want %d", fields[0], errnoOK)
+ }
+ nested, _ := splitOutput(t, data, 2)
+ if nested[0] != 2 {
+ t.Errorf("nested successful calls: got %d, want 2", nested[0])
+ }
+ if nested[1] != errnoBudgetExhausted {
+ t.Errorf("nested errno: got %d, want %d", nested[1], errnoBudgetExhausted)
+ }
+ })
+}
+
+// TestHostConcurrentExecutions runs many executions through one Service at once.
+// Each host module closes over its own call's state, so a shared budget or a
+// shared runtime would show up here as crossed results or a race.
+func TestHostConcurrentExecutions(t *testing.T) {
+ t.Parallel()
+
+ s := newService(t, compute.Options{Workers: 8})
+ module := loadModule(t, "hostbytesget")
+
+ var wg sync.WaitGroup
+ for i := range 32 {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+
+ // Every execution gets its own host, address and payload, so a
+ // result that belongs to another run is visible immediately.
+ seed := byte(i + 1)
+ payload := bytes.Repeat([]byte{seed}, 64)
+ host := newMockHost()
+ addr := host.addData(seed, payload)
+
+ res, err := s.Execute(t.Context(), compute.Request{
+ Module: module,
+ Input: append(addr.Bytes(), u32(4096)...),
+ Host: host,
+ // One call and one payload each: a budget shared between
+ // executions would starve most of them.
+ Limits: compute.Limits{MaxHostCalls: 1, MaxHostBytes: 64},
+ })
+ if err != nil {
+ // The service refuses rather than queues when every worker is
+ // taken, which is the point of the semaphore, not a failure.
+ if !errors.Is(err, compute.ErrBusy) {
+ t.Errorf("execute: %v", err)
+ }
+ return
+ }
+ if res.Status != compute.StatusOK {
+ t.Errorf("status: got %v, want %v (%s)", res.Status, compute.StatusOK, res.TrapMessage)
+ return
+ }
+ fields, data := splitOutput(t, res.Output, 2)
+ if fields[0] != errnoOK {
+ t.Errorf("errno: got %d, want %d", fields[0], errnoOK)
+ return
+ }
+ if !bytes.Equal(data, payload) {
+ t.Errorf("payload for seed %d: got %q", seed, data)
+ }
+ }()
+ }
+ wg.Wait()
+}
diff --git a/pkg/compute/limits.go b/pkg/compute/limits.go
new file mode 100644
index 00000000000..825f1f9cc4d
--- /dev/null
+++ b/pkg/compute/limits.go
@@ -0,0 +1,126 @@
+// Copyright 2026 The Swarm Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package compute
+
+// Limits bound a single execution. They are supplied per request (clamped by the
+// API layer to the operator-configured maxima) and passed to the engine.
+type Limits struct {
+ // Memory is the maximum linear memory in bytes the module may allocate.
+ Memory uint64
+ // Entrypoint is the exported function to invoke. Empty selects the module's
+ // WASI command entry (`_start`).
+ Entrypoint string
+ // MaxHostCalls bounds how many swarm host calls one execution tree may
+ // make. Zero means defaultMaxHostCalls.
+ MaxHostCalls uint32
+ // MaxHostBytes bounds the total payload, in both directions, that the
+ // swarm host calls of one execution tree may move. Zero means
+ // defaultMaxHostBytes.
+ MaxHostBytes uint64
+ // MaxDepth bounds the number of execution levels a swarm_execute call tree
+ // may reach, the outermost execution included, so 1 permits no nesting at
+ // all. Zero means defaultMaxDepth.
+ MaxDepth uint32
+ // MaxResponseHeaders bounds how many response headers a guest may set.
+ // Zero means defaultMaxResponseHeaders.
+ MaxResponseHeaders uint32
+ // MaxResponseHeaderBytes bounds the total size of the response headers a
+ // guest may set, counting name and value. Zero means
+ // defaultMaxResponseHeaderBytes.
+ //
+ // Unlike the host budgets these are not exposed as per-request headers. That
+ // pattern exists so a caller can lower risk it is exposed to, and a caller is
+ // not exposed to this one: setting a header costs the caller nothing, the
+ // node's exposure is a few kilobytes, and lowering it can only break the
+ // module.
+ MaxResponseHeaderBytes uint32
+}
+
+// Defaults applied when a limit is left unset. They are deliberately modest:
+// this engine has no work-based bound, so the host budgets are what stop a
+// module from making the node fetch or store without end.
+const (
+ defaultMaxHostCalls uint32 = 64
+ defaultMaxHostBytes uint64 = 32 << 20
+ defaultMaxDepth uint32 = 4
+ defaultMaxResponseHeaders uint32 = 32
+ defaultMaxResponseHeaderBytes uint32 = 8 << 10
+)
+
+// Hard caps on a single response header, not configurable. They bound the work a
+// rejected call can cause: both are checked before any guest memory is read, so a
+// value length of 2^32-1 never allocates.
+const (
+ // MaxResponseHeaderNameLen is the longest response header name accepted.
+ MaxResponseHeaderNameLen uint32 = 128
+ // MaxResponseHeaderValueLen is the longest response header value accepted.
+ MaxResponseHeaderValueLen uint32 = 4 << 10
+)
+
+// HostCalls, HostBytes and Depth resolve a limit against its default. They are
+// exported because a Host has to agree with the engine on the numbers: it
+// refuses oversized work before materialising it, which it can only do if it
+// sees the same effective limit the budget was built from.
+
+// HostCalls is the effective host-call limit.
+func (l Limits) HostCalls() uint32 {
+ if l.MaxHostCalls == 0 {
+ return defaultMaxHostCalls
+ }
+ return l.MaxHostCalls
+}
+
+// HostBytes is the effective host-byte limit.
+func (l Limits) HostBytes() uint64 {
+ if l.MaxHostBytes == 0 {
+ return defaultMaxHostBytes
+ }
+ return l.MaxHostBytes
+}
+
+// Depth is the effective limit on execution levels, the outermost included.
+func (l Limits) Depth() uint32 {
+ if l.MaxDepth == 0 {
+ return defaultMaxDepth
+ }
+ return l.MaxDepth
+}
+
+// ResponseHeaders is the effective limit on how many headers a guest may set.
+func (l Limits) ResponseHeaders() uint32 {
+ if l.MaxResponseHeaders == 0 {
+ return defaultMaxResponseHeaders
+ }
+ return l.MaxResponseHeaders
+}
+
+// ResponseHeaderBytes is the effective limit on the total size of a guest's
+// response headers, counting name and value.
+func (l Limits) ResponseHeaderBytes() uint32 {
+ if l.MaxResponseHeaderBytes == 0 {
+ return defaultMaxResponseHeaderBytes
+ }
+ return l.MaxResponseHeaderBytes
+}
+
+const (
+ // wasmPageSize is the size of a single WebAssembly memory page.
+ wasmPageSize = 65536
+ // maxWasmPages is the maximum number of pages a 32-bit WASM memory can address.
+ maxWasmPages = 65536
+)
+
+// memoryPages converts a byte memory limit into a number of WASM pages, rounding
+// up and clamping to the 32-bit maximum. A zero limit returns the maximum.
+func (l Limits) memoryPages() uint32 {
+ if l.Memory == 0 {
+ return maxWasmPages
+ }
+ pages := (l.Memory + wasmPageSize - 1) / wasmPageSize
+ if pages > maxWasmPages {
+ return maxWasmPages
+ }
+ return uint32(pages)
+}
diff --git a/pkg/compute/response_test.go b/pkg/compute/response_test.go
new file mode 100644
index 00000000000..e6a035cdd5f
--- /dev/null
+++ b/pkg/compute/response_test.go
@@ -0,0 +1,346 @@
+// Copyright 2026 The Swarm Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package compute_test
+
+import (
+ "context"
+ "encoding/binary"
+ "strings"
+ "testing"
+
+ "github.com/ethersphere/bee/v2/pkg/compute"
+)
+
+// headerValues collects the values a guest set for one name, in order.
+func headerValues(meta compute.ResponseMeta, name string) []string {
+ var out []string
+ for _, h := range meta.Headers {
+ if strings.EqualFold(h.Name, name) {
+ out = append(out, h.Value)
+ }
+ }
+ return out
+}
+
+func TestResponseMetadata(t *testing.T) {
+ t.Parallel()
+
+ s := newService(t, compute.Options{Workers: 1})
+
+ res, err := s.Execute(context.Background(), compute.Request{
+ Module: loadModule(t, "respok"),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Status != compute.StatusOK {
+ t.Fatalf("status: got %v, want %v", res.Status, compute.StatusOK)
+ }
+ if string(res.Output) != "hi" {
+ t.Errorf("output: got %q, want %q", res.Output, "hi")
+ }
+ if res.Response.Status != 201 {
+ t.Errorf("status: got %d, want 201", res.Response.Status)
+ }
+ if got := headerValues(res.Response, "Content-Type"); len(got) != 1 || got[0] != "text/css" {
+ t.Errorf("content-type: got %v, want [text/css]", got)
+ }
+ if got := headerValues(res.Response, "Cache-Control"); len(got) != 1 || got[0] != "max-age=60" {
+ t.Errorf("cache-control: got %v, want [max-age=60]", got)
+ }
+ // Repeats are kept rather than collapsed: Link and Vary legitimately repeat.
+ if got := headerValues(res.Response, "Link"); len(got) != 1 || got[0] != "; rel=next" {
+ t.Errorf("link: got %v", got)
+ }
+ if res.Response.Empty() {
+ t.Error("Empty() reported true for a guest that set metadata")
+ }
+}
+
+// A module that never calls the response functions must look exactly as it did
+// before they existed, which is what lets the API layer keep its old behaviour.
+func TestResponseMetadataAbsent(t *testing.T) {
+ t.Parallel()
+
+ s := newService(t, compute.Options{Workers: 1})
+
+ res, err := s.Execute(context.Background(), compute.Request{
+ Module: loadModule(t, "writer"),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !res.Response.Empty() {
+ t.Errorf("Empty(): got false, want true (%+v)", res.Response)
+ }
+}
+
+func TestResponseRefusals(t *testing.T) {
+ t.Parallel()
+
+ s := newService(t, compute.Options{Workers: 1})
+
+ res, err := s.Execute(context.Background(), compute.Request{
+ Module: loadModule(t, "respbad"),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Status != compute.StatusOK {
+ t.Fatalf("status: got %v, want %v (nothing may trap)", res.Status, compute.StatusOK)
+ }
+ if len(res.Output) != 24 {
+ t.Fatalf("output: got %d bytes, want 24", len(res.Output))
+ }
+
+ for i, tc := range []struct {
+ name string
+ want uint32
+ }{
+ {"CR/LF in a header name", errnoInvalid},
+ {"forging Swarm-Wasm-Status", errnoDenied},
+ {"widening CORS", errnoDenied},
+ {"setting a cookie", errnoDenied},
+ {"a 99 status code", errnoInvalid},
+ {"an absurd value length", errnoInvalid},
+ } {
+ if got := binary.LittleEndian.Uint32(res.Output[i*4:]); got != tc.want {
+ t.Errorf("%s: got errno %d, want %d", tc.name, got, tc.want)
+ }
+ }
+
+ // Every call was refused, so nothing may have been recorded.
+ if !res.Response.Empty() {
+ t.Errorf("refused calls left metadata behind: %+v", res.Response)
+ }
+}
+
+func TestResponseHeaderCaps(t *testing.T) {
+ t.Parallel()
+
+ for _, tc := range []struct {
+ name string
+ limits compute.Limits
+ wantMax int
+ }{
+ {
+ // 15 bytes a header ("X-Pad" + 10), so the count cap binds first.
+ name: "count cap",
+ limits: compute.Limits{MaxResponseHeaders: 4, MaxResponseHeaderBytes: 8 << 10},
+ wantMax: 4,
+ },
+ {
+ name: "byte cap",
+ limits: compute.Limits{MaxResponseHeaders: 1000, MaxResponseHeaderBytes: 45},
+ wantMax: 3,
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ s := newService(t, compute.Options{Workers: 1})
+
+ res, err := s.Execute(context.Background(), compute.Request{
+ Module: loadModule(t, "respflood"),
+ Limits: tc.limits,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Status != compute.StatusOK {
+ t.Fatalf("status: got %v, want %v", res.Status, compute.StatusOK)
+ }
+
+ accepted := int(binary.LittleEndian.Uint32(res.Output[0:]))
+ code := binary.LittleEndian.Uint32(res.Output[4:])
+ if accepted != tc.wantMax {
+ t.Errorf("accepted: got %d, want %d", accepted, tc.wantMax)
+ }
+ if code != errnoBudgetExhausted {
+ t.Errorf("stopping errno: got %d, want %d", code, errnoBudgetExhausted)
+ }
+ if len(res.Response.Headers) != tc.wantMax {
+ t.Errorf("recorded headers: got %d, want %d", len(res.Response.Headers), tc.wantMax)
+ }
+ })
+ }
+}
+
+// A trap discards response metadata the way it discards uploads, while partial
+// output survives as evidence. The asymmetry is deliberate.
+func TestResponseMetadataDroppedOnTrap(t *testing.T) {
+ t.Parallel()
+
+ s := newService(t, compute.Options{Workers: 1})
+
+ res, err := s.Execute(context.Background(), compute.Request{
+ Module: loadModule(t, "resptrap"),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Status != compute.StatusTrap {
+ t.Fatalf("status: got %v, want %v", res.Status, compute.StatusTrap)
+ }
+ if !res.Response.Empty() {
+ t.Errorf("a trapped module kept its response metadata: %+v", res.Response)
+ }
+ if string(res.Output) != "part" {
+ t.Errorf("output: got %q, want %q (partial output is evidence)", res.Output, "part")
+ }
+}
+
+// Shaping the response causes no node work, so it must not require a Host. The
+// data functions must still be rejected in that configuration.
+func TestResponseWithoutNodeAccess(t *testing.T) {
+ t.Parallel()
+
+ s := newService(t, compute.Options{Workers: 1})
+
+ res, err := s.Execute(context.Background(), compute.Request{
+ Module: loadModule(t, "respok"),
+ // No Host.
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Status != compute.StatusOK {
+ t.Fatalf("status: got %v, want %v", res.Status, compute.StatusOK)
+ }
+ if res.Response.Status != 201 {
+ t.Errorf("status: got %d, want 201", res.Response.Status)
+ }
+}
+
+// A module reached through swarm_execute is a library call, not an HTTP request.
+// Letting it set headers would let a module fetched from Swarm rewrite its
+// caller's response, so it is refused outright.
+func TestResponseRefusedWhenNested(t *testing.T) {
+ t.Parallel()
+
+ t.Run("outermost is allowed", func(t *testing.T) {
+ t.Parallel()
+
+ host := newMockHost()
+ res := runHost(t, host, "respcode", nil, compute.Limits{})
+ if res.Status != compute.StatusOK {
+ t.Fatalf("status: got %v, want %v", res.Status, compute.StatusOK)
+ }
+ if got := binary.LittleEndian.Uint32(res.Output); got != errnoOK {
+ t.Errorf("errno: got %d, want %d", got, errnoOK)
+ }
+ if got := headerValues(res.Response, "X-Depth"); len(got) != 1 {
+ t.Errorf("header not recorded: %v", got)
+ }
+ })
+
+ t.Run("nested is denied", func(t *testing.T) {
+ t.Parallel()
+
+ host := newMockHost()
+ addr := host.addData(2, loadModule(t, "respcode"))
+
+ res := runHost(t, host, "hostnested", addr.Bytes(), compute.Limits{})
+ if res.Status != compute.StatusOK {
+ t.Fatalf("status: got %v, want %v (%s)", res.Status, compute.StatusOK, res.TrapMessage)
+ }
+
+ fields, data := splitOutput(t, res.Output, 2)
+ if fields[0] != errnoOK {
+ t.Fatalf("swarm_execute errno: got %d, want %d", fields[0], errnoOK)
+ }
+ if got := binary.LittleEndian.Uint32(data); got != errnoDenied {
+ t.Errorf("nested errno: got %d, want %d", got, errnoDenied)
+ }
+ // The child's attempt must leave nothing on the parent's response.
+ if !res.Response.Empty() {
+ t.Errorf("a nested module set metadata on its caller: %+v", res.Response)
+ }
+ })
+}
+
+// splitEnv splits the environment block a guest dumped into its entries.
+func splitEnv(out []byte) []string {
+ if len(out) == 0 {
+ return nil
+ }
+ return strings.Split(string(out), "\x00")
+}
+
+func TestExecuteEnv(t *testing.T) {
+ t.Parallel()
+
+ t.Run("entries reach the guest in the order given", func(t *testing.T) {
+ t.Parallel()
+
+ s := newService(t, compute.Options{Workers: 1})
+
+ res, err := s.Execute(context.Background(), compute.Request{
+ Module: loadModule(t, "method"),
+ Method: "GET",
+ Env: []compute.EnvVar{
+ {Name: "SCRIPT_NAME", Value: "/@/abc"},
+ {Name: "PATH_INFO", Value: "/style.css"},
+ {Name: "QUERY_STRING", Value: "a=1&b=2"},
+ {Name: "HTTP_ACCEPT", Value: "text/css"},
+ },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ want := []string{
+ // Method still comes first: it owns REQUEST_METHOD.
+ "REQUEST_METHOD=GET",
+ "SCRIPT_NAME=/@/abc",
+ "PATH_INFO=/style.css",
+ "QUERY_STRING=a=1&b=2",
+ "HTTP_ACCEPT=text/css",
+ }
+ got := splitEnv(res.Output)
+ if len(got) != len(want) {
+ t.Fatalf("env: got %q, want %q", got, want)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Errorf("env[%d]: got %q, want %q", i, got[i], want[i])
+ }
+ }
+ })
+
+ t.Run("malformed entries are refused", func(t *testing.T) {
+ t.Parallel()
+
+ s := newService(t, compute.Options{Workers: 1})
+
+ res, err := s.Execute(context.Background(), compute.Request{
+ Module: loadModule(t, "method"),
+ Method: "GET",
+ Env: []compute.EnvVar{
+ // A duplicate REQUEST_METHOD would give the guest two entries
+ // for one name.
+ {Name: "REQUEST_METHOD", Value: "PUT"},
+ // "=" and NUL would corrupt the flat environ block.
+ {Name: "BAD=NAME", Value: "x"},
+ {Name: "BAD\x00NAME", Value: "x"},
+ {Name: "OK_NAME", Value: "kept"},
+ },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ want := []string{"REQUEST_METHOD=GET", "OK_NAME=kept"}
+ got := splitEnv(res.Output)
+ if len(got) != len(want) {
+ t.Fatalf("env: got %q, want %q", got, want)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Errorf("env[%d]: got %q, want %q", i, got[i], want[i])
+ }
+ }
+ })
+}
diff --git a/pkg/compute/testdata/README.md b/pkg/compute/testdata/README.md
new file mode 100644
index 00000000000..25f6077199f
--- /dev/null
+++ b/pkg/compute/testdata/README.md
@@ -0,0 +1,52 @@
+# compute test fixtures
+
+Each `*.wasm` module in this directory has its WebAssembly text source next to it
+as `*.wat`. The `.wat` file is the source of record; regenerate a module after
+editing it with either assembler:
+
+ wasm-tools parse .wat -o .wasm
+ wat2wasm .wat -o .wasm
+
+The modules are deliberately tiny and hand-written so the sandbox behaviour they
+exercise stays obvious.
+
+## Sandbox fixtures
+
+Output, traps, exits, rejected imports, memory limits, non-termination and
+request metadata: `writer`, `echo`, `entrypoint`, `exit1`, `trap`, `infloop`,
+`bigmem`, `badimport`, `method`.
+
+## Host fixtures
+
+These import the `swarm` module and exercise the guest ABI (see
+`../README.md`). They read their arguments from stdin and write fixed-width
+little-endian fields to stdout so a test can assert the exact result code a
+module observes:
+
+| Fixture | stdin | stdout |
+|---|---|---|
+| `hostbytesget` | `[32-byte address][4-byte buffer length]` | `[errno][required length][payload]` |
+| `hostbytesput` | `[32-byte batch id][payload]` | `[errno][32-byte reference]` |
+| `hostchunk` | `[32-byte batch id][chunk data]` | `[put errno][get errno][retrieved chunk]` |
+| `hostnested` | `[32-byte module address][nested input]` | `[errno][output length][nested output]` |
+| `hostcalls` | `[32-byte address]` | `[successful calls][errno that stopped the loop]` |
+| `hostbadptr` | — | `[errno]` |
+| `hostputtrap` | `[32-byte batch id][payload]` | `[errno][32-byte reference]`, then traps |
+| `hostunknown` | — | — (imports a function the host module does not define) |
+
+## Response fixtures
+
+These import `swarm_response_status` and `swarm_response_header`, which shape the
+HTTP response and need no `Host` — `respok`, `respbad` and `respflood` run with
+node access switched off.
+
+| Fixture | stdin | stdout |
+|---|---|---|
+| `respok` | — | `hi`, having set status 201 and three headers, one of them a repeat |
+| `respbad` | — | `[6 x errno]`: CR/LF in a name, a `Swarm-Wasm-Status` override, an `Access-Control-*` name, `Set-Cookie`, status 99, a value length of `0xffffffff` |
+| `respflood` | — | `[accepted count][errno that stopped the loop]` — observes the count and byte caps |
+| `resptrap` | — | `part`, having set a status and a header, then traps |
+| `respcode` | — | `[errno]` from one valid header: `OK` outermost, `DENIED` when nested |
+
+Each fixture writes its payload field only when the call succeeded, so a
+non-zero result code yields the fixed-width fields alone.
diff --git a/pkg/compute/testdata/badimport.wasm b/pkg/compute/testdata/badimport.wasm
new file mode 100644
index 00000000000..59566497e48
Binary files /dev/null and b/pkg/compute/testdata/badimport.wasm differ
diff --git a/pkg/compute/testdata/badimport.wat b/pkg/compute/testdata/badimport.wat
new file mode 100644
index 00000000000..9e587a779b8
--- /dev/null
+++ b/pkg/compute/testdata/badimport.wat
@@ -0,0 +1,4 @@
+;; Imports a host function the sandbox does not provide.
+(module
+ (import "env" "does_not_exist" (func $missing))
+ (func (export "_start") (call $missing)))
diff --git a/pkg/compute/testdata/bigmem.wasm b/pkg/compute/testdata/bigmem.wasm
new file mode 100644
index 00000000000..56693561bed
Binary files /dev/null and b/pkg/compute/testdata/bigmem.wasm differ
diff --git a/pkg/compute/testdata/bigmem.wat b/pkg/compute/testdata/bigmem.wat
new file mode 100644
index 00000000000..e43e1d22cc0
--- /dev/null
+++ b/pkg/compute/testdata/bigmem.wat
@@ -0,0 +1,9 @@
+;; Declares 100 pages (6.4 MiB) of linear memory so it can be rejected by a
+;; lower memory limit.
+(module
+ (import "wasi_snapshot_preview1" "fd_write"
+ (func $fd_write (param i32 i32 i32 i32) (result i32)))
+ (memory (export "memory") 100)
+ (data (i32.const 0) "\08\00\00\00\03\00\00\00big")
+ (func (export "_start")
+ (drop (call $fd_write (i32.const 1) (i32.const 0) (i32.const 1) (i32.const 200)))))
diff --git a/pkg/compute/testdata/echo.wasm b/pkg/compute/testdata/echo.wasm
new file mode 100644
index 00000000000..ee38ce012f7
Binary files /dev/null and b/pkg/compute/testdata/echo.wasm differ
diff --git a/pkg/compute/testdata/echo.wat b/pkg/compute/testdata/echo.wat
new file mode 100644
index 00000000000..737c39a764d
--- /dev/null
+++ b/pkg/compute/testdata/echo.wat
@@ -0,0 +1,15 @@
+;; Reads up to 64 bytes from stdin and writes them straight back to stdout.
+;; The read iovec lives at address 0, the write iovec at address 8, and both
+;; point at the 64-byte scratch buffer at address 16.
+(module
+ (import "wasi_snapshot_preview1" "fd_read"
+ (func $fd_read (param i32 i32 i32 i32) (result i32)))
+ (import "wasi_snapshot_preview1" "fd_write"
+ (func $fd_write (param i32 i32 i32 i32) (result i32)))
+ (memory (export "memory") 1)
+ (data (i32.const 0) "\10\00\00\00\40\00\00\00\10\00\00\00\00\00\00\00")
+ (func (export "_start")
+ (drop (call $fd_read (i32.const 0) (i32.const 0) (i32.const 1) (i32.const 200)))
+ ;; copy the number of bytes read into the length field of the write iovec
+ (i32.store (i32.const 12) (i32.load (i32.const 200)))
+ (drop (call $fd_write (i32.const 1) (i32.const 8) (i32.const 1) (i32.const 204)))))
diff --git a/pkg/compute/testdata/entrypoint.wasm b/pkg/compute/testdata/entrypoint.wasm
new file mode 100644
index 00000000000..95af22edb81
Binary files /dev/null and b/pkg/compute/testdata/entrypoint.wasm differ
diff --git a/pkg/compute/testdata/entrypoint.wat b/pkg/compute/testdata/entrypoint.wat
new file mode 100644
index 00000000000..e4c31b7d436
--- /dev/null
+++ b/pkg/compute/testdata/entrypoint.wat
@@ -0,0 +1,9 @@
+;; Same as writer.wat but the function is exported as `run` and there is no
+;; `_start`, so it can only be reached via an explicit entrypoint.
+(module
+ (import "wasi_snapshot_preview1" "fd_write"
+ (func $fd_write (param i32 i32 i32 i32) (result i32)))
+ (memory (export "memory") 1)
+ (data (i32.const 0) "\08\00\00\00\11\00\00\00entrypoint output")
+ (func (export "run")
+ (drop (call $fd_write (i32.const 1) (i32.const 0) (i32.const 1) (i32.const 200)))))
diff --git a/pkg/compute/testdata/exit1.wasm b/pkg/compute/testdata/exit1.wasm
new file mode 100644
index 00000000000..7d8463ad5c5
Binary files /dev/null and b/pkg/compute/testdata/exit1.wasm differ
diff --git a/pkg/compute/testdata/exit1.wat b/pkg/compute/testdata/exit1.wat
new file mode 100644
index 00000000000..6709207345e
--- /dev/null
+++ b/pkg/compute/testdata/exit1.wat
@@ -0,0 +1,4 @@
+;; Exits with a non-zero WASI exit code.
+(module
+ (import "wasi_snapshot_preview1" "proc_exit" (func $proc_exit (param i32)))
+ (func (export "_start") (call $proc_exit (i32.const 1))))
diff --git a/pkg/compute/testdata/hostbadptr.wasm b/pkg/compute/testdata/hostbadptr.wasm
new file mode 100644
index 00000000000..878e6d071ca
Binary files /dev/null and b/pkg/compute/testdata/hostbadptr.wasm differ
diff --git a/pkg/compute/testdata/hostbadptr.wat b/pkg/compute/testdata/hostbadptr.wat
new file mode 100644
index 00000000000..9f4007c7e52
--- /dev/null
+++ b/pkg/compute/testdata/hostbadptr.wat
@@ -0,0 +1,15 @@
+;; Passes an address pointer far outside linear memory. The host bounds-checks
+;; every offset, so this must come back INVALID (5) rather than trapping.
+;;
+;; stdout: [4-byte errno]
+(module
+ (import "wasi_snapshot_preview1" "fd_write"
+ (func $fd_write (param i32 i32 i32 i32) (result i32)))
+ (import "swarm" "swarm_bytes_get"
+ (func $bytes_get (param i32 i32 i32 i32) (result i32)))
+ (memory (export "memory") 1)
+ (data (i32.const 8) "\20\00\00\00\04\00\00\00")
+ (func (export "_start")
+ (i32.store (i32.const 32)
+ (call $bytes_get (i32.const 0xffff0000) (i32.const 256) (i32.const 4096) (i32.const 40)))
+ (drop (call $fd_write (i32.const 1) (i32.const 8) (i32.const 1) (i32.const 28)))))
diff --git a/pkg/compute/testdata/hostbytesget.wasm b/pkg/compute/testdata/hostbytesget.wasm
new file mode 100644
index 00000000000..31c04e0bd35
Binary files /dev/null and b/pkg/compute/testdata/hostbytesget.wasm differ
diff --git a/pkg/compute/testdata/hostbytesget.wat b/pkg/compute/testdata/hostbytesget.wat
new file mode 100644
index 00000000000..7967b410cda
--- /dev/null
+++ b/pkg/compute/testdata/hostbytesget.wat
@@ -0,0 +1,28 @@
+;; Calls swarm_bytes_get with an address and buffer length taken from stdin.
+;;
+;; stdin: [32-byte address][4-byte buffer length, little-endian]
+;; stdout: [4-byte errno][4-byte required length][payload, only when errno is 0]
+;;
+;; Reporting the required length unconditionally is what lets a test assert the
+;; two-call pattern: a buffer length of 0 comes back BUFFER_TOO_SMALL (4) with
+;; the length the guest would have to allocate.
+;;
+;; memory map: 0 read iovec, 8/16 write iovecs, 24 nread, 28 nwritten,
+;; 32 errno, 36 out_len, 64 address, 96 buffer length, 256 payload buffer.
+(module
+ (import "wasi_snapshot_preview1" "fd_read"
+ (func $fd_read (param i32 i32 i32 i32) (result i32)))
+ (import "wasi_snapshot_preview1" "fd_write"
+ (func $fd_write (param i32 i32 i32 i32) (result i32)))
+ (import "swarm" "swarm_bytes_get"
+ (func $bytes_get (param i32 i32 i32 i32) (result i32)))
+ (memory (export "memory") 1)
+ (data (i32.const 0) "\40\00\00\00\24\00\00\00\20\00\00\00\08\00\00\00\00\01\00\00\00\00\00\00")
+ (func (export "_start")
+ (drop (call $fd_read (i32.const 0) (i32.const 0) (i32.const 1) (i32.const 24)))
+ (i32.store (i32.const 32)
+ (call $bytes_get (i32.const 64) (i32.const 256) (i32.load (i32.const 96)) (i32.const 36)))
+ ;; the payload iovec stays empty unless the call succeeded
+ (if (i32.eqz (i32.load (i32.const 32)))
+ (then (i32.store (i32.const 20) (i32.load (i32.const 36)))))
+ (drop (call $fd_write (i32.const 1) (i32.const 8) (i32.const 2) (i32.const 28)))))
diff --git a/pkg/compute/testdata/hostbytesput.wasm b/pkg/compute/testdata/hostbytesput.wasm
new file mode 100644
index 00000000000..a66381eaac3
Binary files /dev/null and b/pkg/compute/testdata/hostbytesput.wasm differ
diff --git a/pkg/compute/testdata/hostbytesput.wat b/pkg/compute/testdata/hostbytesput.wat
new file mode 100644
index 00000000000..6672855fba2
--- /dev/null
+++ b/pkg/compute/testdata/hostbytesput.wat
@@ -0,0 +1,25 @@
+;; Uploads the payload from stdin with swarm_bytes_put.
+;;
+;; stdin: [32-byte batch id][payload]
+;; stdout: [4-byte errno][32-byte reference, only when errno is 0]
+;;
+;; memory map: 0 read iovec, 8/16 write iovecs, 24 nread, 28 nwritten,
+;; 32 errno, 512 reference, 1024 batch id, 1056 payload.
+(module
+ (import "wasi_snapshot_preview1" "fd_read"
+ (func $fd_read (param i32 i32 i32 i32) (result i32)))
+ (import "wasi_snapshot_preview1" "fd_write"
+ (func $fd_write (param i32 i32 i32 i32) (result i32)))
+ (import "swarm" "swarm_bytes_put"
+ (func $bytes_put (param i32 i32 i32 i32) (result i32)))
+ (memory (export "memory") 1)
+ (data (i32.const 0) "\00\04\00\00\00\40\00\00\20\00\00\00\04\00\00\00\00\02\00\00\20\00\00\00")
+ (func (export "_start")
+ (drop (call $fd_read (i32.const 0) (i32.const 0) (i32.const 1) (i32.const 24)))
+ (i32.store (i32.const 32)
+ (call $bytes_put (i32.const 1024) (i32.const 1056)
+ (i32.sub (i32.load (i32.const 24)) (i32.const 32)) (i32.const 512)))
+ ;; the reference iovec stays empty unless the call succeeded
+ (if (i32.ne (i32.load (i32.const 32)) (i32.const 0))
+ (then (i32.store (i32.const 20) (i32.const 0))))
+ (drop (call $fd_write (i32.const 1) (i32.const 8) (i32.const 2) (i32.const 28)))))
diff --git a/pkg/compute/testdata/hostcalls.wasm b/pkg/compute/testdata/hostcalls.wasm
new file mode 100644
index 00000000000..2aa2d47caa0
Binary files /dev/null and b/pkg/compute/testdata/hostcalls.wasm differ
diff --git a/pkg/compute/testdata/hostcalls.wat b/pkg/compute/testdata/hostcalls.wat
new file mode 100644
index 00000000000..2ac2e5eb471
--- /dev/null
+++ b/pkg/compute/testdata/hostcalls.wat
@@ -0,0 +1,30 @@
+;; Fetches the same address over and over until a host call is refused, so a
+;; test can see exactly where the call budget cuts the module off.
+;;
+;; stdin: [32-byte address]
+;; stdout: [4-byte successful call count][4-byte errno that stopped the loop]
+;;
+;; memory map: 0 read iovec, 8 write iovec, 24 nread, 28 nwritten, 32 count,
+;; 36 errno, 40 out_len, 64 address, 256 payload buffer.
+(module
+ (import "wasi_snapshot_preview1" "fd_read"
+ (func $fd_read (param i32 i32 i32 i32) (result i32)))
+ (import "wasi_snapshot_preview1" "fd_write"
+ (func $fd_write (param i32 i32 i32 i32) (result i32)))
+ (import "swarm" "swarm_bytes_get"
+ (func $bytes_get (param i32 i32 i32 i32) (result i32)))
+ (memory (export "memory") 1)
+ (data (i32.const 0) "\40\00\00\00\20\00\00\00\20\00\00\00\08\00\00\00")
+ (func (export "_start")
+ (local $n i32)
+ (drop (call $fd_read (i32.const 0) (i32.const 0) (i32.const 1) (i32.const 24)))
+ (block $done
+ (loop $again
+ (i32.store (i32.const 36)
+ (call $bytes_get (i32.const 64) (i32.const 256) (i32.const 4096) (i32.const 40)))
+ (br_if $done (i32.ne (i32.load (i32.const 36)) (i32.const 0)))
+ (local.set $n (i32.add (local.get $n) (i32.const 1)))
+ (i32.store (i32.const 32) (local.get $n))
+ ;; a safety stop, so a budget that never bites cannot hang the test
+ (br_if $again (i32.lt_u (local.get $n) (i32.const 10000)))))
+ (drop (call $fd_write (i32.const 1) (i32.const 8) (i32.const 1) (i32.const 28)))))
diff --git a/pkg/compute/testdata/hostchunk.wasm b/pkg/compute/testdata/hostchunk.wasm
new file mode 100644
index 00000000000..d0edefcdc81
Binary files /dev/null and b/pkg/compute/testdata/hostchunk.wasm differ
diff --git a/pkg/compute/testdata/hostchunk.wat b/pkg/compute/testdata/hostchunk.wat
new file mode 100644
index 00000000000..33005b9907a
--- /dev/null
+++ b/pkg/compute/testdata/hostchunk.wat
@@ -0,0 +1,32 @@
+;; Stores a chunk with swarm_chunk_put and reads it straight back with
+;; swarm_chunk_get, so a test sees the raw chunk pair round-trip.
+;;
+;; stdin: [32-byte batch id][chunk data, span included]
+;; stdout: [4-byte put errno][4-byte get errno][retrieved chunk data]
+;;
+;; memory map: 0 read iovec, 8/16 write iovecs, 24 nread, 28 nwritten,
+;; 32 put errno, 36 get errno, 40 out_len, 512 reference, 1024 batch id,
+;; 1056 chunk data, 16384 retrieved buffer.
+(module
+ (import "wasi_snapshot_preview1" "fd_read"
+ (func $fd_read (param i32 i32 i32 i32) (result i32)))
+ (import "wasi_snapshot_preview1" "fd_write"
+ (func $fd_write (param i32 i32 i32 i32) (result i32)))
+ (import "swarm" "swarm_chunk_put"
+ (func $chunk_put (param i32 i32 i32 i32) (result i32)))
+ (import "swarm" "swarm_chunk_get"
+ (func $chunk_get (param i32 i32 i32 i32) (result i32)))
+ (memory (export "memory") 1)
+ (data (i32.const 0) "\00\04\00\00\00\20\00\00\20\00\00\00\08\00\00\00\00\40\00\00\00\00\00\00")
+ (func (export "_start")
+ (drop (call $fd_read (i32.const 0) (i32.const 0) (i32.const 1) (i32.const 24)))
+ (i32.store (i32.const 32)
+ (call $chunk_put (i32.const 1024) (i32.const 1056)
+ (i32.sub (i32.load (i32.const 24)) (i32.const 32)) (i32.const 512)))
+ (if (i32.eqz (i32.load (i32.const 32)))
+ (then
+ (i32.store (i32.const 36)
+ (call $chunk_get (i32.const 512) (i32.const 16384) (i32.const 4104) (i32.const 40)))
+ (if (i32.eqz (i32.load (i32.const 36)))
+ (then (i32.store (i32.const 20) (i32.load (i32.const 40)))))))
+ (drop (call $fd_write (i32.const 1) (i32.const 8) (i32.const 2) (i32.const 28)))))
diff --git a/pkg/compute/testdata/hostnested.wasm b/pkg/compute/testdata/hostnested.wasm
new file mode 100644
index 00000000000..ff36889d6fb
Binary files /dev/null and b/pkg/compute/testdata/hostnested.wasm differ
diff --git a/pkg/compute/testdata/hostnested.wat b/pkg/compute/testdata/hostnested.wat
new file mode 100644
index 00000000000..4317f417cb8
--- /dev/null
+++ b/pkg/compute/testdata/hostnested.wat
@@ -0,0 +1,26 @@
+;; Runs another module with swarm_execute and forwards its output.
+;;
+;; stdin: [32-byte module address][input for the nested module]
+;; stdout: [4-byte errno][4-byte nested output length][nested output]
+;;
+;; memory map: 0 read iovec, 8/16 write iovecs, 24 nread, 28 nwritten,
+;; 32 errno, 36 out_len, 1024 module address, 1056 nested input,
+;; 16384 nested output buffer.
+(module
+ (import "wasi_snapshot_preview1" "fd_read"
+ (func $fd_read (param i32 i32 i32 i32) (result i32)))
+ (import "wasi_snapshot_preview1" "fd_write"
+ (func $fd_write (param i32 i32 i32 i32) (result i32)))
+ (import "swarm" "swarm_execute"
+ (func $execute (param i32 i32 i32 i32 i32 i32) (result i32)))
+ (memory (export "memory") 1)
+ (data (i32.const 0) "\00\04\00\00\00\20\00\00\20\00\00\00\08\00\00\00\00\40\00\00\00\00\00\00")
+ (func (export "_start")
+ (drop (call $fd_read (i32.const 0) (i32.const 0) (i32.const 1) (i32.const 24)))
+ (i32.store (i32.const 32)
+ (call $execute (i32.const 1024) (i32.const 1056)
+ (i32.sub (i32.load (i32.const 24)) (i32.const 32))
+ (i32.const 16384) (i32.const 8192) (i32.const 36)))
+ (if (i32.eqz (i32.load (i32.const 32)))
+ (then (i32.store (i32.const 20) (i32.load (i32.const 36)))))
+ (drop (call $fd_write (i32.const 1) (i32.const 8) (i32.const 2) (i32.const 28)))))
diff --git a/pkg/compute/testdata/hostputtrap.wasm b/pkg/compute/testdata/hostputtrap.wasm
new file mode 100644
index 00000000000..568d76872da
Binary files /dev/null and b/pkg/compute/testdata/hostputtrap.wasm differ
diff --git a/pkg/compute/testdata/hostputtrap.wat b/pkg/compute/testdata/hostputtrap.wat
new file mode 100644
index 00000000000..c71aec73c9f
--- /dev/null
+++ b/pkg/compute/testdata/hostputtrap.wat
@@ -0,0 +1,25 @@
+;; Uploads the payload from stdin and then traps, so a test can check that a
+;; failed run leaves nothing behind: the chunks it wrote must be dropped, not
+;; handed to the pusher.
+;;
+;; stdin: [32-byte batch id][payload]
+;; stdout: [4-byte errno][32-byte reference] — written before the trap, so the
+;; reference is visible even though the run failed.
+(module
+ (import "wasi_snapshot_preview1" "fd_read"
+ (func $fd_read (param i32 i32 i32 i32) (result i32)))
+ (import "wasi_snapshot_preview1" "fd_write"
+ (func $fd_write (param i32 i32 i32 i32) (result i32)))
+ (import "swarm" "swarm_bytes_put"
+ (func $bytes_put (param i32 i32 i32 i32) (result i32)))
+ (memory (export "memory") 1)
+ (data (i32.const 0) "\00\04\00\00\00\40\00\00\20\00\00\00\04\00\00\00\00\02\00\00\20\00\00\00")
+ (func (export "_start")
+ (drop (call $fd_read (i32.const 0) (i32.const 0) (i32.const 1) (i32.const 24)))
+ (i32.store (i32.const 32)
+ (call $bytes_put (i32.const 1024) (i32.const 1056)
+ (i32.sub (i32.load (i32.const 24)) (i32.const 32)) (i32.const 512)))
+ (if (i32.ne (i32.load (i32.const 32)) (i32.const 0))
+ (then (i32.store (i32.const 20) (i32.const 0))))
+ (drop (call $fd_write (i32.const 1) (i32.const 8) (i32.const 2) (i32.const 28)))
+ (unreachable)))
diff --git a/pkg/compute/testdata/hostunknown.wasm b/pkg/compute/testdata/hostunknown.wasm
new file mode 100644
index 00000000000..7d7a76b0f20
Binary files /dev/null and b/pkg/compute/testdata/hostunknown.wasm differ
diff --git a/pkg/compute/testdata/hostunknown.wat b/pkg/compute/testdata/hostunknown.wat
new file mode 100644
index 00000000000..7e4f5358413
--- /dev/null
+++ b/pkg/compute/testdata/hostunknown.wat
@@ -0,0 +1,7 @@
+;; Imports a function the swarm host module does not define. Imports are checked
+;; before instantiation, so this is StatusInvalidModule and never a link trap.
+(module
+ (import "swarm" "swarm_nope" (func $nope (param i32) (result i32)))
+ (memory (export "memory") 1)
+ (func (export "_start")
+ (drop (call $nope (i32.const 0)))))
diff --git a/pkg/compute/testdata/infloop.wasm b/pkg/compute/testdata/infloop.wasm
new file mode 100644
index 00000000000..90e3dfde126
Binary files /dev/null and b/pkg/compute/testdata/infloop.wasm differ
diff --git a/pkg/compute/testdata/infloop.wat b/pkg/compute/testdata/infloop.wat
new file mode 100644
index 00000000000..27bec472e04
--- /dev/null
+++ b/pkg/compute/testdata/infloop.wat
@@ -0,0 +1,4 @@
+;; Loops forever; only an external interrupt stops it.
+(module
+ (func (export "_start")
+ (loop $l (br $l))))
diff --git a/pkg/compute/testdata/method.wasm b/pkg/compute/testdata/method.wasm
new file mode 100644
index 00000000000..22146e74f2c
Binary files /dev/null and b/pkg/compute/testdata/method.wasm differ
diff --git a/pkg/compute/testdata/method.wat b/pkg/compute/testdata/method.wat
new file mode 100644
index 00000000000..5e46d0f739a
--- /dev/null
+++ b/pkg/compute/testdata/method.wat
@@ -0,0 +1,27 @@
+;; Writes the whole environment block the sandbox provides to stdout, so tests
+;; can observe what request metadata reaches the guest and in what order. Entries
+;; arrive as "NAME=value" separated by NUL; the trailing NUL is dropped, so a
+;; single entry ("REQUEST_METHOD=POST") comes out as itself.
+;;
+;; environ_sizes_get stores the entry count at address 0 and the buffer size at
+;; address 4; environ_get stores the pointer array at 64 and the NUL-terminated
+;; strings at 128. The write iovec lives at address 8 and its length is the
+;; buffer size minus the trailing NUL (zero when no environment is provided).
+(module
+ (import "wasi_snapshot_preview1" "environ_sizes_get"
+ (func $environ_sizes_get (param i32 i32) (result i32)))
+ (import "wasi_snapshot_preview1" "environ_get"
+ (func $environ_get (param i32 i32) (result i32)))
+ (import "wasi_snapshot_preview1" "fd_write"
+ (func $fd_write (param i32 i32 i32 i32) (result i32)))
+ (memory (export "memory") 1)
+ (func (export "_start")
+ (drop (call $environ_sizes_get (i32.const 0) (i32.const 4)))
+ (drop (call $environ_get (i32.const 64) (i32.const 128)))
+ (i32.store (i32.const 8) (i32.const 128))
+ (i32.store (i32.const 12)
+ (select
+ (i32.const 0)
+ (i32.sub (i32.load (i32.const 4)) (i32.const 1))
+ (i32.eqz (i32.load (i32.const 4)))))
+ (drop (call $fd_write (i32.const 1) (i32.const 8) (i32.const 1) (i32.const 16)))))
diff --git a/pkg/compute/testdata/respbad.wasm b/pkg/compute/testdata/respbad.wasm
new file mode 100644
index 00000000000..5bcb2bb968d
Binary files /dev/null and b/pkg/compute/testdata/respbad.wasm differ
diff --git a/pkg/compute/testdata/respbad.wat b/pkg/compute/testdata/respbad.wat
new file mode 100644
index 00000000000..eec0476eb18
--- /dev/null
+++ b/pkg/compute/testdata/respbad.wat
@@ -0,0 +1,47 @@
+;; Every way a response call is refused, in one module. Nothing traps: each call
+;; reports a code the guest could branch on.
+;;
+;; stdout: [6 x 4-byte errno] for, in order:
+;; a name containing CR/LF -> INVALID (5)
+;; a Swarm-Wasm-Status override -> DENIED (2)
+;; Access-Control-Allow-Origin -> DENIED (2)
+;; Set-Cookie -> DENIED (2)
+;; status 99 -> INVALID (5)
+;; a value length of 0xffffffff -> INVALID (5)
+(module
+ (import "wasi_snapshot_preview1" "fd_write"
+ (func $fd_write (param i32 i32 i32 i32) (result i32)))
+ (import "swarm" "swarm_response_status"
+ (func $status (param i32) (result i32)))
+ (import "swarm" "swarm_response_header"
+ (func $header (param i32 i32 i32 i32) (result i32)))
+ (memory (export "memory") 1)
+
+ ;; iovec at 8 -> six results at 256
+ (data (i32.const 8) "\00\01\00\00\18\00\00\00")
+
+ (data (i32.const 64) "X\0d\0aInjected") ;; 64, len 11
+ (data (i32.const 80) "v") ;; 80, len 1
+ (data (i32.const 96) "Swarm-Wasm-Status") ;; 96, len 17
+ (data (i32.const 128) "trap") ;; 128, len 4
+ (data (i32.const 144) "Access-Control-Allow-Origin") ;; 144, len 27
+ (data (i32.const 176) "*") ;; 176, len 1
+ (data (i32.const 192) "Set-Cookie") ;; 192, len 10
+ (data (i32.const 208) "a=b") ;; 208, len 3
+ (data (i32.const 224) "X-Ok") ;; 224, len 4
+
+ (func (export "_start")
+ (i32.store (i32.const 256)
+ (call $header (i32.const 64) (i32.const 11) (i32.const 80) (i32.const 1)))
+ (i32.store (i32.const 260)
+ (call $header (i32.const 96) (i32.const 17) (i32.const 128) (i32.const 4)))
+ (i32.store (i32.const 264)
+ (call $header (i32.const 144) (i32.const 27) (i32.const 176) (i32.const 1)))
+ (i32.store (i32.const 268)
+ (call $header (i32.const 192) (i32.const 10) (i32.const 208) (i32.const 3)))
+ (i32.store (i32.const 272)
+ (call $status (i32.const 99)))
+ ;; An absurd length must be refused before any memory is read.
+ (i32.store (i32.const 276)
+ (call $header (i32.const 224) (i32.const 4) (i32.const 80) (i32.const 0xffffffff)))
+ (drop (call $fd_write (i32.const 1) (i32.const 8) (i32.const 1) (i32.const 4)))))
diff --git a/pkg/compute/testdata/respcode.wasm b/pkg/compute/testdata/respcode.wasm
new file mode 100644
index 00000000000..af5b1e5e828
Binary files /dev/null and b/pkg/compute/testdata/respcode.wasm differ
diff --git a/pkg/compute/testdata/respcode.wat b/pkg/compute/testdata/respcode.wat
new file mode 100644
index 00000000000..f39379dfbfd
--- /dev/null
+++ b/pkg/compute/testdata/respcode.wat
@@ -0,0 +1,20 @@
+;; Sets one valid header and reports the code it got. Run outermost this is OK
+;; (0); reached through swarm_execute it is DENIED (2), because the response
+;; belongs to the outermost execution alone.
+;;
+;; stdout: [4-byte errno]
+(module
+ (import "wasi_snapshot_preview1" "fd_write"
+ (func $fd_write (param i32 i32 i32 i32) (result i32)))
+ (import "swarm" "swarm_response_header"
+ (func $header (param i32 i32 i32 i32) (result i32)))
+ (memory (export "memory") 1)
+
+ (data (i32.const 8) "\20\00\00\00\04\00\00\00")
+ (data (i32.const 64) "X-Depth")
+ (data (i32.const 80) "here")
+
+ (func (export "_start")
+ (i32.store (i32.const 32)
+ (call $header (i32.const 64) (i32.const 7) (i32.const 80) (i32.const 4)))
+ (drop (call $fd_write (i32.const 1) (i32.const 8) (i32.const 1) (i32.const 28)))))
diff --git a/pkg/compute/testdata/respflood.wasm b/pkg/compute/testdata/respflood.wasm
new file mode 100644
index 00000000000..b50ef3b2a8f
Binary files /dev/null and b/pkg/compute/testdata/respflood.wasm differ
diff --git a/pkg/compute/testdata/respflood.wat b/pkg/compute/testdata/respflood.wat
new file mode 100644
index 00000000000..ff4619fd57c
--- /dev/null
+++ b/pkg/compute/testdata/respflood.wat
@@ -0,0 +1,31 @@
+;; Sets distinct headers in a loop until the node refuses, which is how the
+;; count and byte caps are observed from inside the sandbox.
+;;
+;; stdout: [4-byte accepted count][4-byte errno that stopped the loop]
+(module
+ (import "wasi_snapshot_preview1" "fd_write"
+ (func $fd_write (param i32 i32 i32 i32) (result i32)))
+ (import "swarm" "swarm_response_header"
+ (func $header (param i32 i32 i32 i32) (result i32)))
+ (memory (export "memory") 1)
+
+ ;; iovec at 8 -> two results at 256
+ (data (i32.const 8) "\00\01\00\00\08\00\00\00")
+ (data (i32.const 64) "X-Pad")
+ (data (i32.const 80) "0123456789")
+
+ (func (export "_start") (local $n i32) (local $code i32)
+ (block $done
+ (loop $next
+ ;; A distinct name is not needed: duplicates are accepted and each one
+ ;; charges the caps just the same.
+ (local.set $code
+ (call $header (i32.const 64) (i32.const 5) (i32.const 80) (i32.const 10)))
+ (br_if $done (i32.ne (local.get $code) (i32.const 0)))
+ (local.set $n (i32.add (local.get $n) (i32.const 1)))
+ ;; Stop well before forever if the caps were somehow not enforced.
+ (br_if $done (i32.gt_u (local.get $n) (i32.const 10000)))
+ (br $next)))
+ (i32.store (i32.const 256) (local.get $n))
+ (i32.store (i32.const 260) (local.get $code))
+ (drop (call $fd_write (i32.const 1) (i32.const 8) (i32.const 1) (i32.const 4)))))
diff --git a/pkg/compute/testdata/respok.wasm b/pkg/compute/testdata/respok.wasm
new file mode 100644
index 00000000000..d0fefb92246
Binary files /dev/null and b/pkg/compute/testdata/respok.wasm differ
diff --git a/pkg/compute/testdata/respok.wat b/pkg/compute/testdata/respok.wat
new file mode 100644
index 00000000000..7bc7908b4d9
--- /dev/null
+++ b/pkg/compute/testdata/respok.wat
@@ -0,0 +1,32 @@
+;; Sets a status and three response headers, then writes a body. The third
+;; header repeats a name, which is legitimate (Link, Vary) and must be kept in
+;; order rather than collapsed.
+;;
+;; stdout: "hi"
+(module
+ (import "wasi_snapshot_preview1" "fd_write"
+ (func $fd_write (param i32 i32 i32 i32) (result i32)))
+ (import "swarm" "swarm_response_status"
+ (func $status (param i32) (result i32)))
+ (import "swarm" "swarm_response_header"
+ (func $header (param i32 i32 i32 i32) (result i32)))
+ (memory (export "memory") 1)
+
+ ;; iovec at 8 -> the body at 32
+ (data (i32.const 8) "\20\00\00\00\02\00\00\00")
+ (data (i32.const 32) "hi")
+
+ ;; header names and values, laid out end to end
+ (data (i32.const 64) "Content-Type") ;; 64, len 12
+ (data (i32.const 80) "text/css") ;; 80, len 8
+ (data (i32.const 96) "Cache-Control") ;; 96, len 13
+ (data (i32.const 112) "max-age=60") ;; 112, len 10
+ (data (i32.const 128) "Link") ;; 128, len 4
+ (data (i32.const 144) "; rel=next") ;; 144, len 14
+
+ (func (export "_start")
+ (drop (call $status (i32.const 201)))
+ (drop (call $header (i32.const 64) (i32.const 12) (i32.const 80) (i32.const 8)))
+ (drop (call $header (i32.const 96) (i32.const 13) (i32.const 112) (i32.const 10)))
+ (drop (call $header (i32.const 128) (i32.const 4) (i32.const 144) (i32.const 14)))
+ (drop (call $fd_write (i32.const 1) (i32.const 8) (i32.const 1) (i32.const 4)))))
diff --git a/pkg/compute/testdata/resptrap.wasm b/pkg/compute/testdata/resptrap.wasm
new file mode 100644
index 00000000000..57fd301cda3
Binary files /dev/null and b/pkg/compute/testdata/resptrap.wasm differ
diff --git a/pkg/compute/testdata/resptrap.wat b/pkg/compute/testdata/resptrap.wat
new file mode 100644
index 00000000000..8c9403587bd
--- /dev/null
+++ b/pkg/compute/testdata/resptrap.wat
@@ -0,0 +1,22 @@
+;; Sets a status and a header, writes output, then traps. The output survives as
+;; evidence; the response metadata does not, exactly as a trapped module's
+;; uploads do not.
+(module
+ (import "wasi_snapshot_preview1" "fd_write"
+ (func $fd_write (param i32 i32 i32 i32) (result i32)))
+ (import "swarm" "swarm_response_status"
+ (func $status (param i32) (result i32)))
+ (import "swarm" "swarm_response_header"
+ (func $header (param i32 i32 i32 i32) (result i32)))
+ (memory (export "memory") 1)
+
+ (data (i32.const 8) "\20\00\00\00\04\00\00\00")
+ (data (i32.const 32) "part")
+ (data (i32.const 64) "Content-Type")
+ (data (i32.const 80) "text/plain")
+
+ (func (export "_start")
+ (drop (call $status (i32.const 418)))
+ (drop (call $header (i32.const 64) (i32.const 12) (i32.const 80) (i32.const 10)))
+ (drop (call $fd_write (i32.const 1) (i32.const 8) (i32.const 1) (i32.const 4)))
+ (unreachable)))
diff --git a/pkg/compute/testdata/trap.wasm b/pkg/compute/testdata/trap.wasm
new file mode 100644
index 00000000000..e0de1700251
Binary files /dev/null and b/pkg/compute/testdata/trap.wasm differ
diff --git a/pkg/compute/testdata/trap.wat b/pkg/compute/testdata/trap.wat
new file mode 100644
index 00000000000..4a7b60fc5f4
--- /dev/null
+++ b/pkg/compute/testdata/trap.wat
@@ -0,0 +1,3 @@
+;; Traps immediately on the unreachable instruction.
+(module
+ (func (export "_start") unreachable))
diff --git a/pkg/compute/testdata/writer.wasm b/pkg/compute/testdata/writer.wasm
new file mode 100644
index 00000000000..386075fac1d
Binary files /dev/null and b/pkg/compute/testdata/writer.wasm differ
diff --git a/pkg/compute/testdata/writer.wat b/pkg/compute/testdata/writer.wat
new file mode 100644
index 00000000000..16502b87636
--- /dev/null
+++ b/pkg/compute/testdata/writer.wat
@@ -0,0 +1,9 @@
+;; Writes "hello swarm" to stdout (fd 1) and returns from the WASI
+;; command entrypoint `_start`.
+(module
+ (import "wasi_snapshot_preview1" "fd_write"
+ (func $fd_write (param i32 i32 i32 i32) (result i32)))
+ (memory (export "memory") 1)
+ (data (i32.const 0) "\08\00\00\00\0b\00\00\00hello swarm")
+ (func (export "_start")
+ (drop (call $fd_write (i32.const 1) (i32.const 0) (i32.const 1) (i32.const 200)))))
diff --git a/pkg/compute/wazero.go b/pkg/compute/wazero.go
new file mode 100644
index 00000000000..7df55281f92
--- /dev/null
+++ b/pkg/compute/wazero.go
@@ -0,0 +1,289 @@
+// Copyright 2026 The Swarm Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package compute
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "strings"
+
+ "github.com/ethersphere/bee/v2/pkg/log"
+ "github.com/tetratelabs/wazero"
+ "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
+ "github.com/tetratelabs/wazero/sys"
+)
+
+const (
+ // wasiModuleName is the only host module the sandbox instantiates and
+ // therefore the only one a guest may import from.
+ wasiModuleName = wasi_snapshot_preview1.ModuleName
+ // wasiEntrypoint is the entrypoint of a WASI command module.
+ wasiEntrypoint = "_start"
+ // envRequestMethod is the CGI-style environment variable carrying the HTTP
+ // method the execute endpoint was called with.
+ envRequestMethod = "REQUEST_METHOD"
+)
+
+// wazeroEngine is the in-process execution engine.
+//
+// WARNING: it does NOT meter execution deterministically, it wires WASI
+// stdin/stdout for I/O, and a module reaching the node through the swarm host
+// module observes node-local state. Its output is not reproducible across
+// nodes. See the package documentation.
+type wazeroEngine struct {
+ logger log.Logger
+}
+
+func newWazeroEngine(logger log.Logger) *wazeroEngine {
+ return &wazeroEngine{logger: logger}
+}
+
+// Execute compiles and runs the module, feeding the input on stdin and returning
+// whatever the module writes to stdout as the result. A fresh runtime is created
+// per call so no state leaks between executions.
+func (e *wazeroEngine) Execute(ctx context.Context, req Request) (Result, error) {
+ // The response state is created once, here, and handed only to the outermost
+ // execution. Nested modules see a nil one and are refused.
+ return e.execute(ctx, req, newBudget(req.Limits), newResponseState(req.Limits), 0)
+}
+
+// execute runs one module at the given nesting depth. The budget is shared by
+// pointer across the whole call tree, so a module cannot multiply its host-call
+// allowance by recursing through swarm_execute.
+func (e *wazeroEngine) execute(ctx context.Context, req Request, b *budget, resp *responseState, depth uint32) (res Result, err error) {
+ // Attached on the way out rather than at each return site: a run can finish
+ // through classifyRunError (a WASI exit 0 is a clean run reported as an error
+ // by wazero), and a return-site edit would miss that path.
+ defer func() {
+ if res.Status == StatusOK {
+ res.Response = resp.snapshot()
+ }
+ }()
+
+ e.logger.Debug("execute: starting", "module_size", len(req.Module), "input_size", len(req.Input), "method", req.Method, "entrypoint", req.Limits.Entrypoint, "memory_limit", req.Limits.Memory, "depth", depth)
+
+ cfg := wazero.NewRuntimeConfig().
+ // Interrupt execution when the context (watchdog) is cancelled.
+ WithCloseOnContextDone(true).
+ // Enforce the memory ceiling in-engine.
+ WithMemoryLimitPages(req.Limits.memoryPages())
+
+ r := wazero.NewRuntimeWithConfig(ctx, cfg)
+ defer r.Close(ctx)
+
+ if _, err := wasi_snapshot_preview1.Instantiate(ctx, r); err != nil {
+ // Failing to provide the host environment is an infrastructure fault.
+ return Result{Status: StatusHostError, TrapMessage: err.Error()}, err
+ }
+
+ // The swarm module is built per execution, closing over this tree's budget
+ // and depth. Nothing is shared between untrusted programs.
+ // The swarm module is built per execution, closing over this tree's budget and
+ // depth, and is always instantiated: the response half needs no Host.
+ hs := &hostState{
+ host: req.Host,
+ budget: b,
+ depth: depth,
+ maxDepth: req.Limits.Depth(),
+ logger: e.logger,
+ }
+ // Only the outermost execution owns the response. Nested modules get a nil
+ // responseState, which is what makes swarm_response_* return DENIED.
+ if depth == 0 {
+ hs.resp = resp
+ }
+ if req.Host != nil {
+ hs.nested = func(ctx context.Context, module, input []byte) (Result, error) {
+ nested := req
+ nested.Module = module
+ nested.Input = input
+ // A nested module is always run as a WASI command: the caller's
+ // entrypoint header describes the outermost module only.
+ nested.Limits.Entrypoint = ""
+ return e.execute(ctx, nested, b, resp, depth+1)
+ }
+ }
+ if err := buildSwarmModule(ctx, r, hs); err != nil {
+ return Result{Status: StatusHostError, TrapMessage: err.Error()}, err
+ }
+
+ compiled, err := r.CompileModule(ctx, req.Module)
+ if err != nil {
+ e.logger.Debug("execute: compile failed", "error", err)
+ return Result{Status: StatusInvalidModule, TrapMessage: err.Error()}, nil
+ }
+ e.logger.Debug("execute: compiled", "exports", compiled.ExportedFunctions(), "imported_memories", len(compiled.ImportedMemories()))
+
+ // Reject anything the sandbox does not provide up front, so an unsatisfiable
+ // import is a deterministic verdict on the module rather than a link failure
+ // surfacing as a trap.
+ // hs is always non-nil now that the response half needs no Host, so node
+ // availability is req.Host, not the presence of a hostState.
+ if err := checkImports(compiled, req.Host != nil); err != nil {
+ e.logger.Debug("execute: rejected import", "error", err)
+ return Result{Status: StatusInvalidModule, TrapMessage: err.Error()}, nil
+ }
+
+ // Without an explicit entrypoint the module is run as a WASI command, which
+ // requires the conventional `_start` export.
+ if req.Limits.Entrypoint == "" {
+ if _, ok := compiled.ExportedFunctions()[wasiEntrypoint]; !ok {
+ e.logger.Debug("execute: missing entrypoint export", "want", wasiEntrypoint)
+ return Result{Status: StatusInvalidModule, TrapMessage: "module does not export " + wasiEntrypoint}, nil
+ }
+ }
+
+ var stdout bytes.Buffer
+ modCfg := wazero.NewModuleConfig().
+ WithName("").
+ WithStdin(bytes.NewReader(req.Input)).
+ WithStdout(&stdout).
+ WithStderr(io.Discard)
+
+ // Request metadata is exposed CGI-style. Only values derived from the request
+ // are passed; the host environment is never inherited, so the guest sees the
+ // same environment on every node.
+ if req.Method != "" {
+ modCfg = modCfg.WithEnv(envRequestMethod, req.Method)
+ }
+ for _, v := range req.Env {
+ // Method owns REQUEST_METHOD; a duplicate would give the guest two
+ // entries for one name.
+ if v.Name == envRequestMethod {
+ continue
+ }
+ // A name carrying "=" or NUL would corrupt the WASI environ block, which
+ // is a flat run of NUL-terminated "name=value" strings. The API layer
+ // sanitises too; this is the engine refusing to emit a malformed block
+ // whatever it is handed.
+ if strings.ContainsAny(v.Name, "=\x00") || strings.ContainsRune(v.Value, 0) {
+ continue
+ }
+ modCfg = modCfg.WithEnv(v.Name, v.Value)
+ }
+
+ // With an explicit entrypoint, disable the automatic `_start` invocation and
+ // call the named export ourselves after instantiation.
+ if req.Limits.Entrypoint != "" {
+ modCfg = modCfg.WithStartFunctions()
+ }
+
+ // For a WASI command module `_start` runs during instantiation, so a host
+ // call — and a host abort — can happen here.
+ mod, err := r.InstantiateModule(ctx, compiled, modCfg)
+ if err != nil {
+ e.logger.Debug("execute: instantiate failed", "error", err)
+ if res, aborted := hostAbort(hs); aborted {
+ return res, hs.err
+ }
+ if res, ok := classifyRunError(err, stdout.Bytes()); ok {
+ e.logger.Debug("execute: instantiate error classified", "status", res.Status)
+ return res, nil
+ }
+ return Result{Status: StatusHostError, TrapMessage: err.Error()}, err
+ }
+ defer mod.Close(ctx)
+
+ if req.Limits.Entrypoint != "" {
+ fn := mod.ExportedFunction(req.Limits.Entrypoint)
+ if fn == nil {
+ e.logger.Debug("execute: entrypoint not exported", "entrypoint", req.Limits.Entrypoint)
+ return Result{Status: StatusInvalidModule, TrapMessage: "entrypoint not exported: " + req.Limits.Entrypoint}, nil
+ }
+ if _, err := fn.Call(ctx); err != nil {
+ e.logger.Debug("execute: entrypoint call failed", "entrypoint", req.Limits.Entrypoint, "error", err)
+ if res, aborted := hostAbort(hs); aborted {
+ return res, hs.err
+ }
+ if res, ok := classifyRunError(err, stdout.Bytes()); ok {
+ e.logger.Debug("execute: entrypoint error classified", "status", res.Status)
+ return res, nil
+ }
+ return Result{Status: StatusHostError, TrapMessage: err.Error()}, err
+ }
+ }
+
+ // Defence in depth: a recorded host failure outranks an apparently clean run.
+ if res, aborted := hostAbort(hs); aborted {
+ return res, hs.err
+ }
+
+ e.logger.Debug("execute: ok", "output_size", stdout.Len())
+ return Result{Status: StatusOK, Output: stdout.Bytes()}, nil
+}
+
+// hostAbort reports whether the run was torn down by a node-local host failure
+// and, if so, the verdict to return. A host abort unwinds the guest as a trap,
+// but a trap is a verdict on the program and this was not one: it must surface
+// as StatusHostError with a non-nil error, never as StatusTrap.
+func hostAbort(hs *hostState) (Result, bool) {
+ if hs == nil || hs.err == nil {
+ return Result{}, false
+ }
+ return Result{Status: StatusHostError, TrapMessage: hs.err.Error()}, true
+}
+
+// checkImports verifies the module only imports from the host environment the
+// sandbox instantiates. Importing memory is not supported at all.
+//
+// Rejecting up front means an unsatisfiable import is a deterministic verdict on
+// the module (StatusInvalidModule) rather than a link failure surfacing as a
+// trap. The WASI namespace is accepted wholesale — wasi_snapshot_preview1
+// provides all of preview1 — while the swarm namespace is checked name by name
+// against what buildSwarmModule actually defines.
+func checkImports(compiled wazero.CompiledModule, hostAvailable bool) error {
+ for _, f := range compiled.ImportedFunctions() {
+ moduleName, name, ok := f.Import()
+ if !ok {
+ continue
+ }
+ switch moduleName {
+ case wasiModuleName:
+ case swarmModuleName:
+ if _, ok := swarmResponseExports[name]; ok {
+ // Shaping the response causes no node work, so it needs no Host.
+ continue
+ }
+ if !hostAvailable {
+ return fmt.Errorf("import %q from module %q: node access is not available", name, moduleName)
+ }
+ if _, ok := swarmHostExports[name]; !ok {
+ return fmt.Errorf("unknown import %q from module %q", name, moduleName)
+ }
+ default:
+ return fmt.Errorf("unsupported import %q from module %q", name, moduleName)
+ }
+ }
+ if len(compiled.ImportedMemories()) > 0 {
+ return errors.New("importing memory is not supported")
+ }
+ return nil
+}
+
+// classifyRunError maps a wazero execution error to a program verdict. The bool
+// result is false when the error is not a program-level failure (i.e. it should
+// be surfaced as an infrastructure error).
+func classifyRunError(err error, out []byte) (Result, bool) {
+ var exitErr *sys.ExitError
+ if errors.As(err, &exitErr) {
+ switch exitErr.ExitCode() {
+ case 0:
+ // Normal WASI exit.
+ return Result{Status: StatusOK, Output: out}, true
+ case sys.ExitCodeContextCanceled, sys.ExitCodeDeadlineExceeded:
+ // The watchdog or the caller's context stopped the module. This is a
+ // local, non-deterministic kill, not a verdict on the program.
+ return Result{}, false
+ }
+ return Result{Status: StatusTrap, Output: out, TrapMessage: err.Error()}, true
+ }
+ // Any other execution error is a trap (unreachable, OOB access, ...).
+ return Result{Status: StatusTrap, Output: out, TrapMessage: err.Error()}, true
+}
+
+func (e *wazeroEngine) Close() error { return nil }
diff --git a/pkg/node/node.go b/pkg/node/node.go
index b085278969a..f3288a54d93 100644
--- a/pkg/node/node.go
+++ b/pkg/node/node.go
@@ -30,6 +30,7 @@ import (
"github.com/ethersphere/bee/v2/pkg/accounting"
"github.com/ethersphere/bee/v2/pkg/addressbook"
"github.com/ethersphere/bee/v2/pkg/api"
+ "github.com/ethersphere/bee/v2/pkg/compute"
"github.com/ethersphere/bee/v2/pkg/config"
"github.com/ethersphere/bee/v2/pkg/crypto"
"github.com/ethersphere/bee/v2/pkg/feeds/factory"
@@ -91,6 +92,10 @@ import (
// LoggerName is the tree path name of the logger for this package.
const LoggerName = "node"
+// maxWasmWorkers caps the automatically chosen number of concurrent WASM
+// executions so a many-core node does not devote all of it to untrusted code.
+const maxWasmWorkers = 8
+
type Bee struct {
logger log.Logger
p2pService io.Closer
@@ -126,6 +131,7 @@ type Bee struct {
shutdownMutex sync.Mutex
syncingStopped *syncutil.Signaler
accesscontrolCloser io.Closer
+ computeCloser io.Closer
ethClientCloser func()
}
@@ -198,6 +204,22 @@ type Options struct {
WarmupTime time.Duration
WelcomeMessage string
WhitelistedWithdrawalAddress []string
+ WasmExecuteEnable bool
+ WasmWorkers int
+ WasmExecTimeout time.Duration
+ WasmMaxModuleSize uint64
+ WasmMemory uint64
+ WasmMaxMemory uint64
+ WasmHostCalls uint64
+ WasmMaxHostCalls uint64
+ WasmHostBytes uint64
+ WasmMaxHostBytes uint64
+ WasmExecDepth uint64
+ WasmMaxExecDepth uint64
+ WasmMaxResponseHeaders uint64
+ WasmMaxResponseHeaderBytes uint64
+ WasmRequestHeaders []string
+ WasmMaxEnvBytes uint64
}
const (
@@ -1332,6 +1354,30 @@ func NewBee(
feedFactory := factory.New(localStore.Download(true))
steward := steward.New(localStore, retrieval, localStore.Cache())
+ var computeService compute.Engine
+ if o.WasmExecuteEnable {
+ // A configuration that would hand an untrusted module the operator's
+ // credentials stops the node rather than producing a warning nobody
+ // reads.
+ if err := api.ValidateRequestHeaders(o.WasmRequestHeaders); err != nil {
+ return nil, fmt.Errorf("%s: %w", "wasm-request-headers", err)
+ }
+ workers := o.WasmWorkers
+ if workers < 1 {
+ workers = min(runtime.NumCPU(), maxWasmWorkers)
+ }
+ cs, err := compute.New(compute.Options{
+ Workers: workers,
+ Watchdog: o.WasmExecTimeout,
+ Logger: logger,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("compute service: %w", err)
+ }
+ computeService = cs
+ b.computeCloser = cs
+ }
+
extraOpts := api.ExtraOptions{
Pingpong: pingPong,
TopologyDriver: kad,
@@ -1354,6 +1400,24 @@ func NewBee(
SyncStatus: syncStatusFn,
NodeStatus: nodeStatus,
PinIntegrity: localStore.PinIntegrity(),
+ Compute: computeService,
+ ExecuteConfig: api.ExecuteConfig{
+ MaxModuleSize: o.WasmMaxModuleSize,
+ DefaultMemory: o.WasmMemory,
+ MaxMemory: o.WasmMaxMemory,
+
+ DefaultHostCalls: o.WasmHostCalls,
+ MaxHostCalls: o.WasmMaxHostCalls,
+ DefaultHostBytes: o.WasmHostBytes,
+ MaxHostBytes: o.WasmMaxHostBytes,
+ DefaultDepth: o.WasmExecDepth,
+ MaxDepth: o.WasmMaxExecDepth,
+
+ MaxResponseHeaders: o.WasmMaxResponseHeaders,
+ MaxResponseHeaderBytes: o.WasmMaxResponseHeaderBytes,
+ RequestHeaders: o.WasmRequestHeaders,
+ MaxEnvBytes: o.WasmMaxEnvBytes,
+ },
}
if o.APIAddr != "" {
@@ -1543,6 +1607,7 @@ func (b *Bee) Shutdown() error {
b.ethClientCloser()
}
+ tryClose(b.computeCloser, "compute")
tryClose(b.accesscontrolCloser, "accesscontrol")
tryClose(b.tracerCloser, "tracer")
tryClose(b.topologyCloser, "topology driver")