Pure Go JavaScript/TypeScript execution engine using goja + esbuild. Provides sandboxed code execution with a call() bridge for agent actions, automatic TypeScript detection and transpilation, timeout enforcement, max-calls limits, and a concurrent execution pool. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
274 lines
6.5 KiB
Go
274 lines
6.5 KiB
Go
package jsruntime
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/dop251/goja"
|
|
)
|
|
|
|
// Execute runs JavaScript or TypeScript code in a sandboxed goja VM.
|
|
//
|
|
// If the code looks like TypeScript (contains type annotations, interfaces,
|
|
// generics, etc.), it is automatically transpiled to JavaScript before execution.
|
|
//
|
|
// A global `call(actionName, args)` function is provided that bridges to the
|
|
// supplied ToolCaller. It returns {ok: true, result: ...} or {ok: false, error: ...}.
|
|
//
|
|
// The value of the last expression in the code is returned as ExecuteResult.Value.
|
|
func Execute(ctx context.Context, code string, caller ToolCaller, opts ExecuteOptions) (*ExecuteResult, error) {
|
|
opts = opts.defaults()
|
|
start := time.Now()
|
|
|
|
// Auto-detect and transpile TypeScript
|
|
if looksLikeTypeScript(code) {
|
|
transpiled, err := TranspileTypeScript(code)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
code = transpiled
|
|
}
|
|
|
|
// Handle empty code
|
|
if len(code) == 0 {
|
|
return &ExecuteResult{
|
|
Value: nil,
|
|
CallCount: 0,
|
|
Duration: time.Since(start),
|
|
}, nil
|
|
}
|
|
|
|
// Create VM and set up sandbox
|
|
vm := goja.New()
|
|
setupSandbox(vm)
|
|
|
|
// Track call count
|
|
var callCount int32
|
|
|
|
// Register call() global function
|
|
callFn := makeCallFunction(ctx, vm, caller, opts.MaxCalls, &callCount)
|
|
if err := vm.Set("call", callFn); err != nil {
|
|
return nil, &ExecError{
|
|
Code: CodeRuntimeError,
|
|
Message: fmt.Sprintf("failed to register call() function: %v", err),
|
|
}
|
|
}
|
|
|
|
// Set up timeout via context
|
|
timeoutCtx, cancel := context.WithTimeout(ctx, opts.Timeout)
|
|
defer cancel()
|
|
|
|
// Run in a goroutine so we can enforce timeout
|
|
type execResult struct {
|
|
value goja.Value
|
|
err error
|
|
}
|
|
resultCh := make(chan execResult, 1)
|
|
|
|
go func() {
|
|
// Compile first to get better syntax error reporting
|
|
prog, compileErr := goja.Compile("", code, false)
|
|
if compileErr != nil {
|
|
resultCh <- execResult{err: compileErr}
|
|
return
|
|
}
|
|
val, runErr := vm.RunProgram(prog)
|
|
resultCh <- execResult{value: val, err: runErr}
|
|
}()
|
|
|
|
// Monitor for timeout — interrupt the VM
|
|
go func() {
|
|
<-timeoutCtx.Done()
|
|
if timeoutCtx.Err() == context.DeadlineExceeded {
|
|
vm.Interrupt("execution timeout")
|
|
}
|
|
}()
|
|
|
|
select {
|
|
case res := <-resultCh:
|
|
duration := time.Since(start)
|
|
|
|
if res.err != nil {
|
|
return nil, classifyError(res.err)
|
|
}
|
|
|
|
// Export result
|
|
exported := res.value.Export()
|
|
|
|
// Validate JSON serializability
|
|
if err := validateSerializable(exported); err != nil {
|
|
return nil, &ExecError{
|
|
Code: CodeRuntimeError,
|
|
Message: fmt.Sprintf("result is not JSON-serializable: %v", err),
|
|
}
|
|
}
|
|
|
|
return &ExecuteResult{
|
|
Value: exported,
|
|
CallCount: int(atomic.LoadInt32(&callCount)),
|
|
Duration: duration,
|
|
}, nil
|
|
|
|
case <-timeoutCtx.Done():
|
|
vm.Interrupt("execution timeout")
|
|
return nil, &ExecError{
|
|
Code: CodeTimeout,
|
|
Message: fmt.Sprintf("execution exceeded timeout of %s", opts.Timeout),
|
|
}
|
|
}
|
|
}
|
|
|
|
// setupSandbox disables dangerous global APIs in the VM.
|
|
func setupSandbox(vm *goja.Runtime) {
|
|
// Disable module loading
|
|
vm.Set("require", goja.Undefined())
|
|
vm.Set("import", goja.Undefined())
|
|
|
|
// Disable async operations
|
|
vm.Set("setTimeout", goja.Undefined())
|
|
vm.Set("setInterval", goja.Undefined())
|
|
vm.Set("clearTimeout", goja.Undefined())
|
|
vm.Set("clearInterval", goja.Undefined())
|
|
|
|
// Disable network access
|
|
vm.Set("fetch", goja.Undefined())
|
|
vm.Set("XMLHttpRequest", goja.Undefined())
|
|
|
|
// Disable process/system access
|
|
vm.Set("process", goja.Undefined())
|
|
|
|
// Note: goja does not provide filesystem or network access by default,
|
|
// so we only need to block APIs that could be expected by JS code.
|
|
}
|
|
|
|
// makeCallFunction creates the call(actionName, args) bridge function.
|
|
func makeCallFunction(
|
|
ctx context.Context,
|
|
vm *goja.Runtime,
|
|
caller ToolCaller,
|
|
maxCalls int,
|
|
callCount *int32,
|
|
) func(goja.FunctionCall) goja.Value {
|
|
return func(fc goja.FunctionCall) goja.Value {
|
|
// Validate arguments
|
|
if len(fc.Arguments) < 2 {
|
|
return vm.ToValue(map[string]any{
|
|
"ok": false,
|
|
"error": map[string]any{
|
|
"code": "INVALID_ARGS",
|
|
"message": "call() requires 2 arguments: actionName (string), args (object)",
|
|
},
|
|
})
|
|
}
|
|
|
|
// Extract and validate actionName
|
|
actionName := fc.Arguments[0].String()
|
|
if actionName == "" || actionName == "undefined" {
|
|
return vm.ToValue(map[string]any{
|
|
"ok": false,
|
|
"error": map[string]any{
|
|
"code": "INVALID_ARGS",
|
|
"message": "actionName must be a non-empty string",
|
|
},
|
|
})
|
|
}
|
|
|
|
// Extract and validate args
|
|
argsExported := fc.Arguments[1].Export()
|
|
args, ok := argsExported.(map[string]any)
|
|
if !ok {
|
|
return vm.ToValue(map[string]any{
|
|
"ok": false,
|
|
"error": map[string]any{
|
|
"code": "INVALID_ARGS",
|
|
"message": "args must be an object",
|
|
},
|
|
})
|
|
}
|
|
|
|
// Enforce MaxCalls limit
|
|
count := atomic.AddInt32(callCount, 1)
|
|
if int(count) > maxCalls {
|
|
return vm.ToValue(map[string]any{
|
|
"ok": false,
|
|
"error": map[string]any{
|
|
"code": CodeMaxCallsExceeded,
|
|
"message": fmt.Sprintf("exceeded maximum of %d call() invocations", maxCalls),
|
|
},
|
|
})
|
|
}
|
|
|
|
// Bridge to ToolCaller with context propagation
|
|
result, err := caller.Call(ctx, actionName, args)
|
|
if err != nil {
|
|
slog.Warn("call() bridge error",
|
|
"action", actionName,
|
|
"error", err,
|
|
)
|
|
return vm.ToValue(map[string]any{
|
|
"ok": false,
|
|
"error": map[string]any{
|
|
"code": "CALL_ERROR",
|
|
"message": err.Error(),
|
|
},
|
|
})
|
|
}
|
|
|
|
return vm.ToValue(map[string]any{
|
|
"ok": true,
|
|
"result": result,
|
|
})
|
|
}
|
|
}
|
|
|
|
// classifyError converts a goja error into a structured ExecError.
|
|
func classifyError(err error) *ExecError {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
|
|
// Check for interrupt (timeout)
|
|
if interrupted, ok := err.(*goja.InterruptedError); ok {
|
|
return &ExecError{
|
|
Code: CodeTimeout,
|
|
Message: interrupted.Error(),
|
|
}
|
|
}
|
|
|
|
// Check for syntax error from compilation
|
|
if syntaxErr, ok := err.(*goja.CompilerSyntaxError); ok {
|
|
return &ExecError{
|
|
Code: CodeSyntaxError,
|
|
Message: syntaxErr.Error(),
|
|
}
|
|
}
|
|
|
|
// Check for JS exception (runtime error)
|
|
if exception, ok := err.(*goja.Exception); ok {
|
|
return &ExecError{
|
|
Code: CodeRuntimeError,
|
|
Message: exception.Error(),
|
|
Stack: exception.String(),
|
|
}
|
|
}
|
|
|
|
// Generic error
|
|
return &ExecError{
|
|
Code: CodeRuntimeError,
|
|
Message: err.Error(),
|
|
}
|
|
}
|
|
|
|
// validateSerializable checks whether the value can be marshaled to JSON.
|
|
func validateSerializable(value any) error {
|
|
if value == nil {
|
|
return nil
|
|
}
|
|
_, err := json.Marshal(value)
|
|
return err
|
|
}
|