Files
synapbus/internal/jsruntime/runtime.go
T
Algis DumbrisandClaude Opus 4.6 e8165511de feat: add sandboxed JS/TS runtime engine (internal/jsruntime)
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>
2026-03-15 07:53:25 +02:00

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
}