mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-07 18:18:00 +00:00
feat(task): replace built-in task adaptors with a sandboxed JS plugin system (#7076)
This commit is contained in:
@@ -0,0 +1,579 @@
|
||||
package jsplugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/grafana/sobek"
|
||||
"github.com/grafana/sobek/parser"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultCallTimeout = 5 * time.Second
|
||||
DefaultConcurrency = 8
|
||||
)
|
||||
|
||||
var ErrCallAdmissionTimeout = errors.New("plugin call admission timed out")
|
||||
|
||||
const hookErrorMessageLimit = 512
|
||||
|
||||
// HookError reports a JavaScript exception thrown by a plugin hook. Message
|
||||
// is the sanitized JS error message with engine prefixes stripped; it is safe
|
||||
// to surface to API callers.
|
||||
type HookError struct {
|
||||
Hook string
|
||||
Message string
|
||||
wrapped error
|
||||
}
|
||||
|
||||
func (e *HookError) Error() string {
|
||||
if e == nil || e.wrapped == nil {
|
||||
return "plugin hook failed"
|
||||
}
|
||||
return e.wrapped.Error()
|
||||
}
|
||||
|
||||
func (e *HookError) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.wrapped
|
||||
}
|
||||
|
||||
func newHookError(hook, rawMessage string, wrapped error) *HookError {
|
||||
var b strings.Builder
|
||||
b.Grow(len(rawMessage))
|
||||
count := 0
|
||||
for _, r := range rawMessage {
|
||||
if count >= hookErrorMessageLimit {
|
||||
break
|
||||
}
|
||||
if r < 0x20 || (r >= 0x80 && r <= 0x9F) {
|
||||
r = ' '
|
||||
}
|
||||
b.WriteRune(r)
|
||||
count++
|
||||
}
|
||||
message := b.String()
|
||||
if message == "" {
|
||||
message = "plugin hook failed"
|
||||
}
|
||||
return &HookError{Hook: hook, Message: message, wrapped: wrapped}
|
||||
}
|
||||
|
||||
func hookErrorFromException(hook string, exc *sobek.Exception, wrapped error) (hookErr *HookError) {
|
||||
// Reading message/toString executes plugin getters, which can throw again
|
||||
// and panic sobek. By this point the caller's recover is already consumed,
|
||||
// so a second panic would crash the process; fall back to a blank message.
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
hookErr = newHookError(hook, "", wrapped)
|
||||
}
|
||||
}()
|
||||
raw := ""
|
||||
if exc != nil {
|
||||
if val := exc.Value(); val != nil && !sobek.IsUndefined(val) && !sobek.IsNull(val) {
|
||||
gotMessage := false
|
||||
if obj, ok := val.(*sobek.Object); ok {
|
||||
if msg := obj.Get("message"); msg != nil && !sobek.IsUndefined(msg) && !sobek.IsNull(msg) {
|
||||
raw = msg.String()
|
||||
gotMessage = true
|
||||
}
|
||||
}
|
||||
if !gotMessage {
|
||||
if exported, ok := val.Export().(string); ok {
|
||||
raw = exported
|
||||
} else {
|
||||
raw = val.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return newHookError(hook, raw, wrapped)
|
||||
}
|
||||
|
||||
var forbiddenSyntax = regexp.MustCompile(`(?m)(^|[^A-Za-z0-9_$])(async|await|import)([^A-Za-z0-9_$]|$)`)
|
||||
|
||||
type Options struct {
|
||||
Key string
|
||||
Version string
|
||||
Timeout time.Duration
|
||||
Concurrency int
|
||||
Now func() time.Time
|
||||
Log func(string)
|
||||
}
|
||||
|
||||
type Engine struct {
|
||||
key string
|
||||
version string
|
||||
timeout time.Duration
|
||||
now func() time.Time
|
||||
log func(string)
|
||||
module *sobek.SourceTextModuleRecord
|
||||
pool sync.Pool
|
||||
semaphore chan struct{}
|
||||
}
|
||||
|
||||
type runtimeInstance struct {
|
||||
runtime *sobek.Runtime
|
||||
module sobek.ModuleInstance
|
||||
logContext *runtimeLogContext
|
||||
}
|
||||
|
||||
type runtimeLogContext struct {
|
||||
context context.Context
|
||||
}
|
||||
|
||||
// Compile performs upload-time syntax checks and compiles an ESM plugin once.
|
||||
// All Sobek-specific module and runtime handling is intentionally kept here.
|
||||
func Compile(source string, options Options) (*Engine, error) {
|
||||
if match := forbiddenSyntax.FindString(sourceWithoutCommentsAndStrings(source)); match != "" {
|
||||
return nil, fmt.Errorf("unsupported plugin syntax %q: plugins must be synchronous and cannot import modules", strings.TrimSpace(match))
|
||||
}
|
||||
|
||||
resolve := func(_ interface{}, specifier string) (sobek.ModuleRecord, error) {
|
||||
return nil, fmt.Errorf("plugin imports are disabled: %s", specifier)
|
||||
}
|
||||
// Plugin source is untrusted; without this option a sourceMappingURL
|
||||
// comment makes the parser read arbitrary server files via os.ReadFile.
|
||||
module, err := sobek.ParseModule(options.Key+".js", source, resolve, parser.WithDisableSourceMaps)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("compile plugin: %w", err)
|
||||
}
|
||||
if err = module.Link(); err != nil {
|
||||
return nil, fmt.Errorf("link plugin: %w", err)
|
||||
}
|
||||
|
||||
timeout := options.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = DefaultCallTimeout
|
||||
}
|
||||
concurrency := options.Concurrency
|
||||
if concurrency <= 0 {
|
||||
concurrency = DefaultConcurrency
|
||||
}
|
||||
now := options.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
|
||||
engine := &Engine{
|
||||
key: options.Key,
|
||||
version: options.Version,
|
||||
timeout: timeout,
|
||||
now: now,
|
||||
log: options.Log,
|
||||
module: module,
|
||||
semaphore: make(chan struct{}, concurrency),
|
||||
}
|
||||
instance, err := engine.newRuntime(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
instance.logContext.context = nil
|
||||
engine.pool.Put(instance)
|
||||
return engine, nil
|
||||
}
|
||||
|
||||
// Export returns one module export without exposing Sobek values outside the
|
||||
// engine boundary. It is used for declarative exports such as meta.
|
||||
func (e *Engine) Export(ctx context.Context, exportName string) (result any, err error) {
|
||||
select {
|
||||
case e.semaphore <- struct{}{}:
|
||||
defer func() { <-e.semaphore }()
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
instance, err := e.getRuntime(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reusable := true
|
||||
defer func() {
|
||||
instance.runtime.ClearInterrupt()
|
||||
instance.logContext.context = nil
|
||||
if reusable {
|
||||
e.pool.Put(instance)
|
||||
}
|
||||
}()
|
||||
timedOut := errors.New("plugin export timed out")
|
||||
timer := time.AfterFunc(e.timeout, func() { instance.runtime.Interrupt(timedOut) })
|
||||
stopContext := context.AfterFunc(ctx, func() { instance.runtime.Interrupt(ctx.Err()) })
|
||||
defer stopContext()
|
||||
defer timer.Stop()
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
reusable = false
|
||||
switch value := recovered.(type) {
|
||||
case *sobek.InterruptedError:
|
||||
err = fmt.Errorf("plugin %s@%s export %s interrupted: %v", e.key, e.version, exportName, value.Value())
|
||||
case *sobek.Exception:
|
||||
err = fmt.Errorf("plugin %s@%s export %s failed: %v", e.key, e.version, exportName, value)
|
||||
default:
|
||||
panic(recovered)
|
||||
}
|
||||
}
|
||||
}()
|
||||
value := instance.module.GetBindingValue(exportName)
|
||||
if value == nil || sobek.IsUndefined(value) {
|
||||
return nil, fmt.Errorf("plugin export %q not found", exportName)
|
||||
}
|
||||
return value.Export(), nil
|
||||
}
|
||||
|
||||
// HasExport reports whether a module export exists. Optional contract hooks
|
||||
// should be detected with this method instead of relying on engine errors.
|
||||
func (e *Engine) HasExport(ctx context.Context, exportName string) (bool, error) {
|
||||
select {
|
||||
case e.semaphore <- struct{}{}:
|
||||
defer func() { <-e.semaphore }()
|
||||
case <-ctx.Done():
|
||||
return false, ctx.Err()
|
||||
}
|
||||
instance, err := e.getRuntime(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer func() {
|
||||
instance.logContext.context = nil
|
||||
e.pool.Put(instance)
|
||||
}()
|
||||
value := instance.module.GetBindingValue(exportName)
|
||||
return value != nil && !sobek.IsUndefined(value), nil
|
||||
}
|
||||
|
||||
// HasCallablePath reports whether an exported value, or a nested member below
|
||||
// it, exists and is callable.
|
||||
func (e *Engine) HasCallablePath(ctx context.Context, exportName string, members ...string) (found bool, err error) {
|
||||
select {
|
||||
case e.semaphore <- struct{}{}:
|
||||
defer func() { <-e.semaphore }()
|
||||
case <-ctx.Done():
|
||||
return false, ctx.Err()
|
||||
}
|
||||
instance, err := e.getRuntime(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
reusable := true
|
||||
defer func() {
|
||||
instance.runtime.ClearInterrupt()
|
||||
instance.logContext.context = nil
|
||||
if reusable {
|
||||
e.pool.Put(instance)
|
||||
}
|
||||
}()
|
||||
timedOut := errors.New("plugin inspection timed out")
|
||||
timer := time.AfterFunc(e.timeout, func() { instance.runtime.Interrupt(timedOut) })
|
||||
stopContext := context.AfterFunc(ctx, func() { instance.runtime.Interrupt(ctx.Err()) })
|
||||
defer stopContext()
|
||||
defer timer.Stop()
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
reusable = false
|
||||
hookName := strings.Join(append([]string{exportName}, members...), ".")
|
||||
switch value := recovered.(type) {
|
||||
case *sobek.InterruptedError:
|
||||
err = fmt.Errorf("plugin %s@%s hook %s inspection interrupted: %v", e.key, e.version, hookName, value.Value())
|
||||
case *sobek.Exception:
|
||||
err = fmt.Errorf("plugin %s@%s hook %s inspection failed: %v", e.key, e.version, hookName, value)
|
||||
default:
|
||||
panic(recovered)
|
||||
}
|
||||
}
|
||||
}()
|
||||
value, _, found := resolveExportPath(instance, exportName, members)
|
||||
if !found {
|
||||
return false, nil
|
||||
}
|
||||
_, callable := sobek.AssertFunction(value)
|
||||
return callable, nil
|
||||
}
|
||||
|
||||
// Call invokes one named module export and returns its JSON-compatible value.
|
||||
func (e *Engine) Call(ctx context.Context, exportName string, args ...any) (result any, err error) {
|
||||
return e.call(ctx, 0, exportName, nil, args...)
|
||||
}
|
||||
|
||||
// CallMember invokes a function stored on an exported object, such as a
|
||||
// renderer in the renderers export.
|
||||
func (e *Engine) CallMember(ctx context.Context, exportName, memberName string, args ...any) (result any, err error) {
|
||||
return e.call(ctx, 0, exportName, []string{memberName}, args...)
|
||||
}
|
||||
|
||||
// CallPath invokes a function nested below an exported object. It is used for
|
||||
// protocol hooks such as protocols.openai_responses.renderEvents.
|
||||
func (e *Engine) CallPath(ctx context.Context, exportName string, members []string, args ...any) (result any, err error) {
|
||||
return e.call(ctx, 0, exportName, members, args...)
|
||||
}
|
||||
|
||||
// CallPathWithAdmissionTimeout gives long-lived observers a separate bound for
|
||||
// waiting on JavaScript capacity. Once admitted, the hook receives the
|
||||
// engine's full execution timeout instead of inheriting time already spent in
|
||||
// the semaphore queue.
|
||||
func (e *Engine) CallPathWithAdmissionTimeout(
|
||||
ctx context.Context,
|
||||
admissionTimeout time.Duration,
|
||||
exportName string,
|
||||
members []string,
|
||||
args ...any,
|
||||
) (result any, err error) {
|
||||
return e.call(ctx, admissionTimeout, exportName, members, args...)
|
||||
}
|
||||
|
||||
func (e *Engine) call(
|
||||
ctx context.Context,
|
||||
admissionTimeout time.Duration,
|
||||
exportName string,
|
||||
members []string,
|
||||
args ...any,
|
||||
) (result any, err error) {
|
||||
if err = e.acquireCallSlot(ctx, admissionTimeout); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { <-e.semaphore }()
|
||||
|
||||
instance, err := e.getRuntime(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reusable := true
|
||||
defer func() {
|
||||
instance.runtime.ClearInterrupt()
|
||||
instance.logContext.context = nil
|
||||
if reusable {
|
||||
e.pool.Put(instance)
|
||||
}
|
||||
}()
|
||||
|
||||
hookName := strings.Join(append([]string{exportName}, members...), ".")
|
||||
timedOut := errors.New("plugin call timed out")
|
||||
timer := time.AfterFunc(e.timeout, func() { instance.runtime.Interrupt(timedOut) })
|
||||
stopContext := context.AfterFunc(ctx, func() { instance.runtime.Interrupt(ctx.Err()) })
|
||||
defer stopContext()
|
||||
defer timer.Stop()
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
reusable = false
|
||||
switch value := recovered.(type) {
|
||||
case *sobek.InterruptedError:
|
||||
err = fmt.Errorf("plugin %s@%s hook %s interrupted: %v", e.key, e.version, hookName, value.Value())
|
||||
case *sobek.Exception:
|
||||
wrapped := fmt.Errorf("plugin %s@%s hook %s failed: %v", e.key, e.version, hookName, value)
|
||||
err = hookErrorFromException(hookName, value, wrapped)
|
||||
default:
|
||||
panic(recovered)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
value, resolvedHookName, found := resolveExportPath(instance, exportName, members)
|
||||
hookName = resolvedHookName
|
||||
if !found {
|
||||
if len(members) == 0 {
|
||||
return nil, fmt.Errorf("plugin export %q not found", exportName)
|
||||
}
|
||||
return nil, fmt.Errorf("plugin hook %q not found", hookName)
|
||||
}
|
||||
if value == nil || sobek.IsUndefined(value) {
|
||||
return nil, fmt.Errorf("plugin export %q not found", exportName)
|
||||
}
|
||||
callable, ok := sobek.AssertFunction(value)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("plugin hook %q is not a function", hookName)
|
||||
}
|
||||
|
||||
callArgs := make([]sobek.Value, len(args))
|
||||
for i, arg := range args {
|
||||
callArgs[i] = instance.runtime.ToValue(arg)
|
||||
}
|
||||
|
||||
value, err = callable(sobek.Undefined(), callArgs...)
|
||||
if err != nil {
|
||||
var interrupted *sobek.InterruptedError
|
||||
if errors.As(err, &interrupted) {
|
||||
reusable = false
|
||||
return nil, fmt.Errorf("plugin %s@%s hook %s failed: %w", e.key, e.version, hookName, err)
|
||||
}
|
||||
wrapped := fmt.Errorf("plugin %s@%s hook %s failed: %w", e.key, e.version, hookName, err)
|
||||
var exc *sobek.Exception
|
||||
if errors.As(err, &exc) {
|
||||
return nil, hookErrorFromException(hookName, exc, wrapped)
|
||||
}
|
||||
return nil, wrapped
|
||||
}
|
||||
return value.Export(), nil
|
||||
}
|
||||
|
||||
func (e *Engine) acquireCallSlot(ctx context.Context, admissionTimeout time.Duration) error {
|
||||
if admissionTimeout <= 0 {
|
||||
select {
|
||||
case e.semaphore <- struct{}{}:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
timer := time.NewTimer(admissionTimeout)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case e.semaphore <- struct{}{}:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("%w: plugin %s@%s", ErrCallAdmissionTimeout, e.key, e.version)
|
||||
}
|
||||
}
|
||||
|
||||
func resolveExportPath(instance *runtimeInstance, exportName string, members []string) (sobek.Value, string, bool) {
|
||||
value := instance.module.GetBindingValue(exportName)
|
||||
hookName := exportName
|
||||
if value == nil || sobek.IsUndefined(value) || sobek.IsNull(value) {
|
||||
return nil, hookName, false
|
||||
}
|
||||
for _, member := range members {
|
||||
hookName += "." + member
|
||||
object := value.ToObject(instance.runtime)
|
||||
own := false
|
||||
for _, name := range object.GetOwnPropertyNames() {
|
||||
if name == member {
|
||||
own = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !own {
|
||||
return nil, hookName, false
|
||||
}
|
||||
value = object.Get(member)
|
||||
if value == nil || sobek.IsUndefined(value) || sobek.IsNull(value) {
|
||||
return nil, hookName, false
|
||||
}
|
||||
}
|
||||
return value, hookName, true
|
||||
}
|
||||
|
||||
func (e *Engine) getRuntime(ctx context.Context) (*runtimeInstance, error) {
|
||||
if pooled := e.pool.Get(); pooled != nil {
|
||||
instance := pooled.(*runtimeInstance)
|
||||
instance.logContext.context = ctx
|
||||
return instance, nil
|
||||
}
|
||||
return e.newRuntime(ctx)
|
||||
}
|
||||
|
||||
func (e *Engine) newRuntime(ctx context.Context) (instance *runtimeInstance, err error) {
|
||||
runtime := sobek.New()
|
||||
logContext := &runtimeLogContext{context: ctx}
|
||||
logOutput := e.log
|
||||
if logOutput == nil {
|
||||
logOutput = func(message string) {
|
||||
logger.LogDebug(logContext.context, "task_plugin subsystem=runtime event=console message=%q", message)
|
||||
}
|
||||
}
|
||||
if err := injectGlobals(runtime, func() string {
|
||||
return fmt.Sprintf("[plugin:%s@%s]", e.key, e.version)
|
||||
}, e.now, logOutput); err != nil {
|
||||
return nil, fmt.Errorf("inject plugin utils: %w", err)
|
||||
}
|
||||
timedOut := errors.New("plugin initialization timed out")
|
||||
timer := time.AfterFunc(e.timeout, func() { runtime.Interrupt(timedOut) })
|
||||
defer timer.Stop()
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
if interrupted, ok := recovered.(*sobek.InterruptedError); ok {
|
||||
instance = nil
|
||||
err = fmt.Errorf("initialize plugin %s@%s: %v", e.key, e.version, interrupted.Value())
|
||||
return
|
||||
}
|
||||
panic(recovered)
|
||||
}
|
||||
}()
|
||||
promise := runtime.CyclicModuleRecordEvaluate(e.module, func(_ interface{}, specifier string) (sobek.ModuleRecord, error) {
|
||||
return nil, fmt.Errorf("plugin imports are disabled: %s", specifier)
|
||||
})
|
||||
if promise.State() != sobek.PromiseStateFulfilled {
|
||||
return nil, fmt.Errorf("evaluate plugin: %v", promise.Result().Export())
|
||||
}
|
||||
return &runtimeInstance{
|
||||
runtime: runtime,
|
||||
module: runtime.GetModuleInstance(e.module),
|
||||
logContext: logContext,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func sourceWithoutCommentsAndStrings(source string) string {
|
||||
var output strings.Builder
|
||||
output.Grow(len(source))
|
||||
quote := byte(0)
|
||||
escaped := false
|
||||
lineComment := false
|
||||
blockComment := false
|
||||
for i := 0; i < len(source); i++ {
|
||||
current := source[i]
|
||||
next := byte(0)
|
||||
if i+1 < len(source) {
|
||||
next = source[i+1]
|
||||
}
|
||||
if lineComment {
|
||||
if current == '\n' {
|
||||
lineComment = false
|
||||
output.WriteByte('\n')
|
||||
} else {
|
||||
output.WriteByte(' ')
|
||||
}
|
||||
continue
|
||||
}
|
||||
if blockComment {
|
||||
if current == '*' && next == '/' {
|
||||
blockComment = false
|
||||
output.WriteString(" ")
|
||||
i++
|
||||
} else {
|
||||
output.WriteByte(' ')
|
||||
}
|
||||
continue
|
||||
}
|
||||
if quote != 0 {
|
||||
output.WriteByte(' ')
|
||||
if escaped {
|
||||
escaped = false
|
||||
} else if current == '\\' {
|
||||
escaped = true
|
||||
} else if current == quote {
|
||||
quote = 0
|
||||
}
|
||||
continue
|
||||
}
|
||||
if current == '/' && next == '/' {
|
||||
lineComment = true
|
||||
output.WriteString(" ")
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if current == '/' && next == '*' {
|
||||
blockComment = true
|
||||
output.WriteString(" ")
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if current == '\'' || current == '"' || current == '`' {
|
||||
quote = current
|
||||
output.WriteByte(' ')
|
||||
continue
|
||||
}
|
||||
output.WriteByte(current)
|
||||
}
|
||||
return output.String()
|
||||
}
|
||||
Reference in New Issue
Block a user