feat: publish v1.0.0-rc.36-custom.1
This commit is contained in:
commit
3a7e8f3b54
2381 files changed
+551494
No files matched your search
@@ -0,0 +1,92 @@
|
||||
package jsplugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// RunCLI implements the `new-api plugin` subcommand: linting a plugin source
|
||||
// and replaying a golden fixture against it. It returns a process exit code.
|
||||
func RunCLI(args []string, stdout, stderr io.Writer) int {
|
||||
if len(args) < 2 {
|
||||
fmt.Fprintln(stderr, "usage: new-api plugin lint <plugin.js> | new-api plugin test <plugin.js> --fixture <fixture.json>")
|
||||
return 2
|
||||
}
|
||||
command, sourcePath := args[0], args[1]
|
||||
source, err := os.ReadFile(sourcePath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "read plugin: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
switch command {
|
||||
case "lint":
|
||||
if len(args) != 2 {
|
||||
fmt.Fprintln(stderr, "usage: new-api plugin lint <plugin.js>")
|
||||
return 2
|
||||
}
|
||||
plugin, compileErr := NewRegistry().Register(string(source), Options{Key: sourcePath, Version: "lint"})
|
||||
if compileErr != nil {
|
||||
fmt.Fprintf(stderr, "plugin lint failed: %v\n", compileErr)
|
||||
return 1
|
||||
}
|
||||
warnParseTaskResultInProgressFallback(string(source), stderr)
|
||||
fmt.Fprintf(stdout, "plugin %s@%s is valid\n", plugin.Meta.Key, plugin.Meta.Version)
|
||||
return 0
|
||||
case "test":
|
||||
if len(args) != 4 || args[2] != "--fixture" || strings.TrimSpace(args[3]) == "" {
|
||||
fmt.Fprintln(stderr, "usage: new-api plugin test <plugin.js> --fixture <fixture.json>")
|
||||
return 2
|
||||
}
|
||||
fixture, readErr := os.ReadFile(args[3])
|
||||
if readErr != nil {
|
||||
fmt.Fprintf(stderr, "read fixture: %v\n", readErr)
|
||||
return 1
|
||||
}
|
||||
report, replayErr := ReplayFixture(context.Background(), string(source), fixture)
|
||||
if replayErr != nil {
|
||||
fmt.Fprintf(stderr, "plugin fixture failed after %d/%d cases: %v\n", report.Passed, report.Total, replayErr)
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintf(stdout, "plugin fixture passed: %d/%d cases\n", report.Passed, report.Total)
|
||||
return 0
|
||||
default:
|
||||
fmt.Fprintf(stderr, "unknown plugin command %q\n", command)
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
func warnParseTaskResultInProgressFallback(source string, stderr io.Writer) {
|
||||
body := parseTaskResultFunctionBody(source)
|
||||
if strings.Contains(body, `|| "IN_PROGRESS"`) || strings.Contains(body, `|| 'IN_PROGRESS'`) {
|
||||
fmt.Fprintln(stderr, `warning: parseTaskResult uses || "IN_PROGRESS" fallback; return UNKNOWN for unrecognized statuses`)
|
||||
}
|
||||
}
|
||||
|
||||
func parseTaskResultFunctionBody(source string) string {
|
||||
marker := strings.Index(source, "function parseTaskResult")
|
||||
if marker < 0 {
|
||||
return ""
|
||||
}
|
||||
brace := strings.Index(source[marker:], "{")
|
||||
if brace < 0 {
|
||||
return ""
|
||||
}
|
||||
start := marker + brace
|
||||
depth := 0
|
||||
for i := start; i < len(source); i++ {
|
||||
switch source[i] {
|
||||
case '{':
|
||||
depth++
|
||||
case '}':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return source[start : i+1]
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package jsplugin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPluginCLI(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
pluginPath := filepath.Join(tempDir, "fixture.js")
|
||||
fixturePath := filepath.Join(tempDir, "fixture.json")
|
||||
require.NoError(t, os.WriteFile(pluginPath, []byte(cliFixturePluginSource), 0o600))
|
||||
require.NoError(t, os.WriteFile(fixturePath, []byte(`{"unixNow":42,"cases":[{"hook":"value","args":[],"expected":42}]}`), 0o600))
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
assert.Equal(t, 0, RunCLI([]string{"lint", pluginPath}, &stdout, &stderr))
|
||||
assert.Contains(t, stdout.String(), "plugin cli-fixture@1.0.0 is valid")
|
||||
assert.Empty(t, stderr.String())
|
||||
|
||||
stdout.Reset()
|
||||
assert.Equal(t, 0, RunCLI([]string{"test", pluginPath, "--fixture", fixturePath}, &stdout, &stderr))
|
||||
assert.Contains(t, stdout.String(), "1/1 cases")
|
||||
}
|
||||
|
||||
func TestPluginCLIWarnsOnParseTaskResultInProgressFallback(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
pluginPath := filepath.Join(tempDir, "fallback.js")
|
||||
require.NoError(t, os.WriteFile(pluginPath, []byte(`
|
||||
export const meta = { apiVersion: 1, key: "fallback", name: "Fallback", version: "1.0.0", author: {name: "Test"}, models: ["m"], fetchMode: "per_task" };
|
||||
export function buildSubmitRequest(ctx) { return {url: ctx.baseUrl}; }
|
||||
export function parseSubmitResponse() { return {taskId: "task"}; }
|
||||
export function buildQueryRequest(ctx) { return {url: ctx.baseUrl}; }
|
||||
export function parseTaskResult(ctx, body) { return {status: statuses[body.status] || "IN_PROGRESS"}; }
|
||||
const statuses = { done: "SUCCESS" };
|
||||
`), 0o600))
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
assert.Equal(t, 0, RunCLI([]string{"lint", pluginPath}, &stdout, &stderr))
|
||||
assert.Contains(t, stdout.String(), "plugin fallback@1.0.0 is valid")
|
||||
assert.Contains(t, stderr.String(), `|| "IN_PROGRESS"`)
|
||||
}
|
||||
|
||||
const cliFixturePluginSource = `
|
||||
export const meta = { apiVersion: 1, key: "cli-fixture", name: "CLI Fixture", version: "1.0.0", author: {name: "Test"}, channelTypes: [1003], models: ["fixture-model"], fetchMode: "per_task" };
|
||||
export function buildSubmitRequest(ctx) { return {url: ctx.baseUrl}; }
|
||||
export function parseSubmitResponse(ctx, resp) { return {taskId: "task"}; }
|
||||
export function buildQueryRequest(ctx) { return {url: ctx.baseUrl}; }
|
||||
export function parseTaskResult(ctx, body) { return body; }
|
||||
export function value() { return utils.unixNow(); }
|
||||
`
|
||||
@@ -0,0 +1,574 @@
|
||||
package jsplugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"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(_ any, 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 := slices.Contains(object.GetOwnPropertyNames(), member)
|
||||
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(_ any, 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()
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
package jsplugin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEngineCallsESMExportWithInjectedUtils(t *testing.T) {
|
||||
t.Parallel()
|
||||
logs := make([]string, 0, 1)
|
||||
engine, err := Compile(`
|
||||
export function sign(ctx) {
|
||||
console.log("called", ctx.name);
|
||||
return {
|
||||
now: utils.unixNow(),
|
||||
digest: utils.hmacSHA256(ctx.message, ctx.secret),
|
||||
encoded: utils.base64(ctx.message),
|
||||
};
|
||||
}
|
||||
export const meta = { apiVersion: 1, key: "mock" };
|
||||
`, Options{
|
||||
Key: "mock", Version: "1.0.0",
|
||||
Now: func() time.Time { return time.Unix(1234, 0) },
|
||||
Log: func(message string) { logs = append(logs, message) },
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := engine.Call(context.Background(), "sign", map[string]any{
|
||||
"name": "fixture", "message": "hello", "secret": "secret",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]any{
|
||||
"now": int64(1234), "digest": "88aab3ede8d3adf94d26ab90d3bafd4a2083070c3bcce9c014ee04a443847c0b", "encoded": "aGVsbG8=",
|
||||
}, result)
|
||||
assert.Equal(t, []string{"[plugin:mock@1.0.0] called fixture"}, logs)
|
||||
|
||||
meta, err := engine.Export(context.Background(), "meta")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]any{"apiVersion": int64(1), "key": "mock"}, meta)
|
||||
}
|
||||
|
||||
func TestEngineConsoleLogUsesDebugLoggerAndRequestContext(t *testing.T) {
|
||||
previousDebug := common.DebugEnabled
|
||||
common.DebugEnabled = false
|
||||
t.Cleanup(func() { common.DebugEnabled = previousDebug })
|
||||
|
||||
var output bytes.Buffer
|
||||
common.LogWriterMu.Lock()
|
||||
previousWriter := gin.DefaultErrorWriter
|
||||
gin.DefaultErrorWriter = &output
|
||||
common.LogWriterMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
common.LogWriterMu.Lock()
|
||||
gin.DefaultErrorWriter = previousWriter
|
||||
common.LogWriterMu.Unlock()
|
||||
})
|
||||
|
||||
plugin, err := CompilePlugin(`
|
||||
export const meta = {
|
||||
apiVersion: 1,
|
||||
key: "console-debug",
|
||||
name: "Console debug",
|
||||
version: "1.2.3",
|
||||
author: {name: "Test"},
|
||||
models: ["debug-model"],
|
||||
fetchMode: "per_task",
|
||||
};
|
||||
export function run(label) {
|
||||
console.log("checkpoint", label);
|
||||
return true;
|
||||
}
|
||||
export function buildSubmitRequest() { return {url: "https://example.com"}; }
|
||||
export function parseSubmitResponse() { return {taskId: "one"}; }
|
||||
export function buildQueryRequest() { return {url: "https://example.com"}; }
|
||||
export function parseTaskResult() { return {status: "SUCCESS"}; }
|
||||
`, Options{})
|
||||
require.NoError(t, err)
|
||||
engine := plugin.Engine
|
||||
|
||||
disabledContext := context.WithValue(context.Background(), common.RequestIdKey, "plugin-console-disabled")
|
||||
_, err = engine.Call(disabledContext, "run", "disabled")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, output.String())
|
||||
|
||||
common.DebugEnabled = true
|
||||
contextA := context.WithValue(context.Background(), common.RequestIdKey, "plugin-console-request-a")
|
||||
_, err = engine.Call(contextA, "run", "context-a")
|
||||
require.NoError(t, err)
|
||||
logA := output.String()
|
||||
output.Reset()
|
||||
|
||||
contextB := context.WithValue(context.Background(), common.RequestIdKey, "plugin-console-request-b")
|
||||
_, err = engine.Call(contextB, "run", "context-b")
|
||||
require.NoError(t, err)
|
||||
logB := output.String()
|
||||
output.Reset()
|
||||
|
||||
_, err = engine.Call(context.Background(), "run", "background")
|
||||
require.NoError(t, err)
|
||||
logBackground := output.String()
|
||||
|
||||
assert.Contains(t, logA, "plugin-console-request-a")
|
||||
assert.NotContains(t, logA, "plugin-console-request-b")
|
||||
assert.Contains(t, logA, "task_plugin subsystem=runtime event=console")
|
||||
assert.Contains(t, logA, "[plugin:console-debug@1.2.3] checkpoint context-a")
|
||||
assert.NotContains(t, logA, "disabled")
|
||||
|
||||
assert.Contains(t, logB, "plugin-console-request-b")
|
||||
assert.NotContains(t, logB, "plugin-console-request-a")
|
||||
assert.Contains(t, logB, "[plugin:console-debug@1.2.3] checkpoint context-b")
|
||||
|
||||
assert.Contains(t, logBackground, "| SYSTEM |")
|
||||
assert.NotContains(t, logBackground, "plugin-console-request-a")
|
||||
assert.NotContains(t, logBackground, "plugin-console-request-b")
|
||||
assert.Contains(t, logBackground, "[plugin:console-debug@1.2.3] checkpoint background")
|
||||
}
|
||||
|
||||
func TestCompileRejectsAsynchronousAndImportedPlugins(t *testing.T) {
|
||||
t.Parallel()
|
||||
for name, source := range map[string]string{
|
||||
"async": `export async function run() {}`,
|
||||
"static import": `import value from "dependency"; export function run() { return value; }`,
|
||||
"dynamic import": `export function run() { return import("dependency"); }`,
|
||||
"top-level await": `const value = await work(); export function run() { return value; }`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := Compile(source, Options{Key: "invalid"})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unsupported plugin syntax")
|
||||
})
|
||||
}
|
||||
|
||||
_, err := Compile(`export function run() { return "import async await"; }`, Options{Key: "valid"})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestCompileIgnoresSourceMapDirectives(t *testing.T) {
|
||||
t.Parallel()
|
||||
// A sourceMappingURL comment must stay inert. Sobek's default loader
|
||||
// os.ReadFiles the referenced server path during Compile and turns any
|
||||
// load failure into a compile error, so an unresolvable path compiling
|
||||
// cleanly proves the loader is disabled.
|
||||
engine, err := Compile("export function run() { return 1; }\n//# sourceMappingURL=/nonexistent/leak-probe.map\n", Options{Key: "sourcemap"})
|
||||
require.NoError(t, err)
|
||||
|
||||
value, err := engine.Call(context.Background(), "run")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), value)
|
||||
}
|
||||
|
||||
func TestEngineInterruptsLongRunningHook(t *testing.T) {
|
||||
t.Parallel()
|
||||
engine, err := Compile(`export function run() { while (true) {} }`, Options{
|
||||
Key: "loop", Version: "1", Timeout: 20 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = engine.Call(context.Background(), "run")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "timed out")
|
||||
var hookErr *HookError
|
||||
assert.False(t, errors.As(err, &hookErr), "timeouts must not be HookError")
|
||||
}
|
||||
|
||||
func TestEngineHookErrorExtractsSanitizedJSMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
source string
|
||||
wantMessage string
|
||||
wantLen int
|
||||
}{
|
||||
{
|
||||
name: "Error object",
|
||||
source: `export function run() { throw new Error("model is required"); }`,
|
||||
wantMessage: "model is required",
|
||||
},
|
||||
{
|
||||
name: "raw string throw",
|
||||
source: `export function run() { throw "raw string"; }`,
|
||||
wantMessage: "raw string",
|
||||
},
|
||||
{
|
||||
name: "truncates to 512 runes",
|
||||
source: `export function run() { throw new Error("x".repeat(2000)); }`,
|
||||
wantLen: 512,
|
||||
wantMessage: strings.Repeat("x", 512),
|
||||
},
|
||||
{
|
||||
name: "scrubs control characters",
|
||||
source: "export function run() { throw new Error(\"line1\\nline2\\x1b[31mred\"); }",
|
||||
wantMessage: "line1 line2 [31mred",
|
||||
},
|
||||
{
|
||||
name: "throwing message getter falls back without crashing",
|
||||
source: `export function run() { throw {get message() { throw {get message() { return "deep"; }}; }}; }`,
|
||||
wantMessage: "plugin hook failed",
|
||||
},
|
||||
}
|
||||
for _, testCase := range tests {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
engine, err := Compile(testCase.source, Options{Key: "diag", Version: "1.0.0"})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = engine.Call(context.Background(), "run")
|
||||
require.Error(t, err)
|
||||
|
||||
var hookErr *HookError
|
||||
require.True(t, errors.As(err, &hookErr))
|
||||
assert.Equal(t, "run", hookErr.Hook)
|
||||
assert.Equal(t, testCase.wantMessage, hookErr.Message)
|
||||
if testCase.wantLen > 0 {
|
||||
assert.Equal(t, testCase.wantLen, utf8.RuneCountInString(hookErr.Message))
|
||||
}
|
||||
assert.Contains(t, hookErr.Error(), "plugin diag@1.0.0")
|
||||
assert.NotContains(t, hookErr.Message, "Error:")
|
||||
assert.NotContains(t, hookErr.Message, "plugin diag@")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineReportsProtocolAdmissionTimeoutSeparately(t *testing.T) {
|
||||
engine, err := Compile(`
|
||||
export const protocols = {
|
||||
responses: {renderEvents: function() { return {events: [], done: false}; }},
|
||||
};
|
||||
`, Options{Key: "admission", Version: "1", Concurrency: 1})
|
||||
require.NoError(t, err)
|
||||
|
||||
engine.semaphore <- struct{}{}
|
||||
_, err = engine.CallPathWithAdmissionTimeout(
|
||||
context.Background(),
|
||||
time.Nanosecond,
|
||||
"protocols",
|
||||
[]string{"responses", "renderEvents"},
|
||||
)
|
||||
<-engine.semaphore
|
||||
|
||||
require.ErrorIs(t, err, ErrCallAdmissionTimeout)
|
||||
result, err := engine.CallPathWithAdmissionTimeout(
|
||||
context.Background(),
|
||||
time.Second,
|
||||
"protocols",
|
||||
[]string{"responses", "renderEvents"},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]any{"events": []any{}, "done": false}, result)
|
||||
}
|
||||
|
||||
func TestEngineExportInterruptsLongRunningGetter(t *testing.T) {
|
||||
t.Parallel()
|
||||
engine, err := Compile(`
|
||||
export const meta = {
|
||||
apiVersion: 1,
|
||||
get name() { while (true) {} },
|
||||
};
|
||||
`, Options{Key: "meta-loop", Version: "1.0.0", Timeout: 20 * time.Millisecond})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = engine.Export(context.Background(), "meta")
|
||||
require.ErrorContains(t, err, "export meta interrupted")
|
||||
}
|
||||
|
||||
func TestEngineExportReturnsThrownGetterError(t *testing.T) {
|
||||
t.Parallel()
|
||||
engine, err := Compile(`
|
||||
export const meta = {
|
||||
apiVersion: 1,
|
||||
get name() { throw new Error("getter failed"); },
|
||||
};
|
||||
`, Options{Key: "meta-throw", Version: "1.0.0"})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = engine.Export(context.Background(), "meta")
|
||||
require.ErrorContains(t, err, "export meta failed")
|
||||
assert.Contains(t, err.Error(), "getter failed")
|
||||
}
|
||||
|
||||
func TestEngineNestedHooksRequireOwnProperties(t *testing.T) {
|
||||
t.Parallel()
|
||||
engine, err := Compile(`
|
||||
const inheritedRenderers = {
|
||||
inherited: function(value) { return value; },
|
||||
constructor: function(value) { return value; },
|
||||
toString: function(value) { return value; },
|
||||
["__proto__"]: function(value) { return value; },
|
||||
};
|
||||
export const renderers = Object.create(inheritedRenderers);
|
||||
renderers.own = function(value) { return {id: value.id}; };
|
||||
|
||||
const inheritedProtocol = {
|
||||
renderFinal: function(value) { return value; },
|
||||
};
|
||||
export const protocols = {
|
||||
responses: Object.create(inheritedProtocol),
|
||||
};
|
||||
`, Options{Key: "own-hooks", Version: "1.0.0"})
|
||||
require.NoError(t, err)
|
||||
|
||||
found, err := engine.HasCallablePath(context.Background(), "renderers", "own")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, found)
|
||||
result, err := engine.CallMember(context.Background(), "renderers", "own", map[string]any{"id": "task-1"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]any{"id": "task-1"}, result)
|
||||
|
||||
for _, member := range []string{"inherited", "constructor", "toString", "__proto__"} {
|
||||
t.Run(member, func(t *testing.T) {
|
||||
found, err := engine.HasCallablePath(context.Background(), "renderers", member)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, found)
|
||||
|
||||
_, err = engine.CallMember(context.Background(), "renderers", member)
|
||||
require.ErrorContains(t, err, "not found")
|
||||
})
|
||||
}
|
||||
|
||||
found, err = engine.HasCallablePath(context.Background(), "protocols", "responses", "renderFinal")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, found)
|
||||
_, err = engine.CallPath(context.Background(), "protocols", []string{"responses", "renderFinal"})
|
||||
require.ErrorContains(t, err, "not found")
|
||||
}
|
||||
|
||||
func TestCompileInterruptsLongRunningInitialization(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := Compile(`while (true) {}; export function run() {}`, Options{
|
||||
Key: "loop", Version: "1", Timeout: 20 * time.Millisecond,
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "initialization timed out")
|
||||
}
|
||||
|
||||
func TestValidateRequestURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
requestURL string
|
||||
baseURL string
|
||||
allowedHosts []string
|
||||
wantError string
|
||||
}{
|
||||
{name: "same host", requestURL: "https://api.example.com/v1/task", baseURL: "https://api.example.com/v1"},
|
||||
{name: "default port", requestURL: "https://api.example.com:443/v1/task", baseURL: "https://api.example.com"},
|
||||
{name: "approved host", requestURL: "https://upload.example.com/task", baseURL: "https://api.example.com", allowedHosts: []string{"upload.example.com"}},
|
||||
{name: "approved host with port", requestURL: "http://192.168.1.10:8080/task", baseURL: "http://192.168.1.10:8000", allowedHosts: []string{"192.168.1.10:8080"}},
|
||||
{name: "approved host with explicit default port", requestURL: "https://upload.example.com/task", baseURL: "https://api.example.com", allowedHosts: []string{"upload.example.com:443"}},
|
||||
{name: "approved host port mismatch", requestURL: "http://192.168.1.10:9000/task", baseURL: "http://192.168.1.10:8000", allowedHosts: []string{"192.168.1.10:8080"}, wantError: "not allowed"},
|
||||
{name: "subdomain is not implicit", requestURL: "https://evil.api.example.com/task", baseURL: "https://api.example.com", wantError: "not allowed"},
|
||||
{name: "userinfo trick", requestURL: "https://api.example.com@evil.example/task", baseURL: "https://api.example.com", wantError: "not allowed"},
|
||||
{name: "relative URL", requestURL: "/v1/task", baseURL: "https://api.example.com", wantError: "absolute"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
err := ValidateRequestURL(test.requestURL, test.baseURL, test.allowedHosts)
|
||||
if test.wantError == "" {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
require.Error(t, err)
|
||||
assert.True(t, strings.Contains(err.Error(), test.wantError), err.Error())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package jsplugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
)
|
||||
|
||||
// Fixture describes deterministic calls into a plugin. Arguments and expected
|
||||
// values stay as JSON so fixtures remain portable across engine implementations.
|
||||
type Fixture struct {
|
||||
UnixNow *int64 `json:"unixNow"`
|
||||
Cases []FixtureCase `json:"cases"`
|
||||
}
|
||||
|
||||
type FixtureCase struct {
|
||||
Name string `json:"name"`
|
||||
Hook string `json:"hook"`
|
||||
Member string `json:"member,omitempty"`
|
||||
Path []string `json:"path,omitempty"`
|
||||
Args []json.RawMessage `json:"args"`
|
||||
Expected json.RawMessage `json:"expected"`
|
||||
ExpectedError string `json:"expectedError,omitempty"`
|
||||
}
|
||||
|
||||
type FixtureReport struct {
|
||||
Total int
|
||||
Passed int
|
||||
}
|
||||
|
||||
// ReplayFixture compiles a plugin and runs every fixture case in declaration
|
||||
// order. unixNow is fixed by the fixture to keep signing and timestamp hooks
|
||||
// reproducible.
|
||||
func ReplayFixture(ctx context.Context, source string, data []byte) (FixtureReport, error) {
|
||||
var fixture Fixture
|
||||
if err := common.Unmarshal(data, &fixture); err != nil {
|
||||
return FixtureReport{}, fmt.Errorf("decode fixture: %w", err)
|
||||
}
|
||||
if len(fixture.Cases) == 0 {
|
||||
return FixtureReport{}, fmt.Errorf("fixture must contain at least one case")
|
||||
}
|
||||
|
||||
options := Options{Key: "fixture", Version: "fixture"}
|
||||
if fixture.UnixNow != nil {
|
||||
fixed := time.Unix(*fixture.UnixNow, 0)
|
||||
options.Now = func() time.Time { return fixed }
|
||||
}
|
||||
plugin, err := NewRegistry().Register(source, options)
|
||||
if err != nil {
|
||||
return FixtureReport{}, fmt.Errorf("compile plugin: %w", err)
|
||||
}
|
||||
|
||||
report := FixtureReport{Total: len(fixture.Cases)}
|
||||
for index, testCase := range fixture.Cases {
|
||||
caseName := strings.TrimSpace(testCase.Name)
|
||||
if caseName == "" {
|
||||
caseName = fmt.Sprintf("case %d", index+1)
|
||||
}
|
||||
if strings.TrimSpace(testCase.Hook) == "" {
|
||||
return report, fmt.Errorf("%s: hook is required", caseName)
|
||||
}
|
||||
|
||||
args := make([]any, len(testCase.Args))
|
||||
for argumentIndex, raw := range testCase.Args {
|
||||
if err = common.Unmarshal(raw, &args[argumentIndex]); err != nil {
|
||||
return report, fmt.Errorf("%s: decode argument %d: %w", caseName, argumentIndex+1, err)
|
||||
}
|
||||
}
|
||||
var result any
|
||||
if testCase.Member != "" && len(testCase.Path) > 0 {
|
||||
return report, fmt.Errorf("%s: member and path are mutually exclusive", caseName)
|
||||
}
|
||||
if len(testCase.Path) > 0 {
|
||||
result, err = plugin.Engine.CallPath(ctx, testCase.Hook, testCase.Path, args...)
|
||||
} else if testCase.Member == "" {
|
||||
result, err = plugin.Engine.Call(ctx, testCase.Hook, args...)
|
||||
} else {
|
||||
result, err = plugin.Engine.CallMember(ctx, testCase.Hook, testCase.Member, args...)
|
||||
}
|
||||
if testCase.ExpectedError != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), testCase.ExpectedError) {
|
||||
return report, fmt.Errorf("%s: expected error containing %q, got %v", caseName, testCase.ExpectedError, err)
|
||||
}
|
||||
report.Passed++
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return report, fmt.Errorf("%s: %w", caseName, err)
|
||||
}
|
||||
if len(testCase.Expected) == 0 {
|
||||
return report, fmt.Errorf("%s: expected is required when expectedError is empty", caseName)
|
||||
}
|
||||
var expected any
|
||||
if err = common.Unmarshal(testCase.Expected, &expected); err != nil {
|
||||
return report, fmt.Errorf("%s: decode expected value: %w", caseName, err)
|
||||
}
|
||||
actualData, marshalErr := common.Marshal(result)
|
||||
if marshalErr != nil {
|
||||
return report, fmt.Errorf("%s: encode actual value: %w", caseName, marshalErr)
|
||||
}
|
||||
var actual any
|
||||
if err = common.Unmarshal(actualData, &actual); err != nil {
|
||||
return report, fmt.Errorf("%s: normalize actual value: %w", caseName, err)
|
||||
}
|
||||
if !reflect.DeepEqual(expected, actual) {
|
||||
return report, fmt.Errorf("%s: result mismatch: expected %s, got %s", caseName, testCase.Expected, actualData)
|
||||
}
|
||||
report.Passed++
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package jsplugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const fixturePlugin = `
|
||||
export const meta = { apiVersion: 1, key: "fixture", name: "Fixture", version: "1.0.0", author: {name: "Test"}, channelTypes: [1002], models: ["fixture-model"], fetchMode: "per_task", protocols: [{name: "openai_responses", supports: ["sync", "background"]}] };
|
||||
export function buildSubmitRequest(ctx) { return {url: ctx.baseUrl + "/submit"}; }
|
||||
export function parseSubmitResponse(ctx, resp) { return {taskId: resp.body.id}; }
|
||||
export function buildQueryRequest(ctx) { return {url: ctx.baseUrl + "/task"}; }
|
||||
export function parseTaskResult(ctx, body) { return body; }
|
||||
export function stamp(value) { return {value: value, now: utils.unixNow()}; }
|
||||
export function fail() { throw new Error("fixture failure"); }
|
||||
export const native = { compact: function(value) { return {id: value.task_id}; } };
|
||||
export const protocols = { openai_responses: {
|
||||
decodeRequest: function(value) { return {kind: "submit", model: value.model}; },
|
||||
renderFinal: function(ctx, task) { return task; }
|
||||
} };
|
||||
`
|
||||
|
||||
func TestReplayFixture(t *testing.T) {
|
||||
t.Parallel()
|
||||
report, err := ReplayFixture(context.Background(), fixturePlugin, []byte(`{
|
||||
"unixNow": 1700000000,
|
||||
"cases": [
|
||||
{"name":"deterministic time","hook":"stamp","args":["ok"],"expected":{"value":"ok","now":1700000000}},
|
||||
{"name":"member call","hook":"native","member":"compact","args":[{"task_id":"task-1"}],"expected":{"id":"task-1"}},
|
||||
{"name":"nested path call","hook":"protocols","path":["openai_responses","decodeRequest"],"args":[{"model":"video-1"}],"expected":{"kind":"submit","model":"video-1"}},
|
||||
{"name":"expected failure","hook":"fail","args":[],"expectedError":"fixture failure"}
|
||||
]
|
||||
}`))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, FixtureReport{Total: 4, Passed: 4}, report)
|
||||
}
|
||||
|
||||
func TestReplayFixtureReportsMismatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
report, err := ReplayFixture(context.Background(), fixturePlugin, []byte(`{
|
||||
"cases": [{"name":"wrong output","hook":"stamp","args":["ok"],"expected":{"value":"different"}}]
|
||||
}`))
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "wrong output: result mismatch")
|
||||
assert.Equal(t, FixtureReport{Total: 1}, report)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package jsplugin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// MaxIconDataURIBytes bounds an uploaded plugin logo. It is deliberately
|
||||
// generous: the plugin source itself is capped at 1 MiB, so this only keeps a
|
||||
// logo from dwarfing the plugin it decorates.
|
||||
const MaxIconDataURIBytes = 512 * 1024
|
||||
|
||||
var pngSignature = []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}
|
||||
|
||||
// DecodeIconDataURI parses a plugin logo shipped as a sidecar icon.svg or
|
||||
// icon.png file and re-encoded by the uploader as
|
||||
// data:image/png;base64,... or data:image/svg+xml;base64,.... It returns the
|
||||
// media type and the raw image bytes. The admin UI renders logos only through
|
||||
// <img>, which already blocks scripts and external loads; the SVG checks here
|
||||
// are defense in depth so a hostile payload is rejected at upload instead of
|
||||
// being stored.
|
||||
func DecodeIconDataURI(icon string) (string, []byte, error) {
|
||||
if len(icon) > MaxIconDataURIBytes {
|
||||
return "", nil, fmt.Errorf("plugin icon must not exceed %d bytes", MaxIconDataURIBytes)
|
||||
}
|
||||
mediaType, payload, ok := strings.Cut(strings.TrimPrefix(icon, "data:"), ";base64,")
|
||||
if !strings.HasPrefix(icon, "data:") || !ok || (mediaType != "image/png" && mediaType != "image/svg+xml") {
|
||||
return "", nil, fmt.Errorf("plugin icon must be data:image/png;base64,... or data:image/svg+xml;base64,...")
|
||||
}
|
||||
decoded, err := base64.StdEncoding.Strict().DecodeString(payload)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("plugin icon payload is not valid base64")
|
||||
}
|
||||
if err := ValidateIconImage(mediaType, decoded); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return mediaType, decoded, nil
|
||||
}
|
||||
|
||||
// ValidateIconImage checks raw PNG or SVG bytes for a plugin logo. PNG must
|
||||
// carry the PNG signature. SVG must be well-formed XML rooted at svg without
|
||||
// script or foreignObject elements, event-handler attributes, directives,
|
||||
// javascript: values, or absolute http(s) references.
|
||||
func ValidateIconImage(mediaType string, data []byte) error {
|
||||
if mediaType == "image/png" {
|
||||
if !bytes.HasPrefix(data, pngSignature) {
|
||||
return fmt.Errorf("plugin icon PNG payload is not a PNG image")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if mediaType != "image/svg+xml" {
|
||||
return fmt.Errorf("plugin icon must be image/png or image/svg+xml")
|
||||
}
|
||||
decoder := xml.NewDecoder(bytes.NewReader(data))
|
||||
rootSeen := false
|
||||
styleDepth := 0
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("plugin icon SVG is not well-formed XML")
|
||||
}
|
||||
switch element := token.(type) {
|
||||
case xml.Directive:
|
||||
return fmt.Errorf("plugin icon SVG must not contain a DOCTYPE or other directives")
|
||||
case xml.ProcInst:
|
||||
if element.Target != "xml" {
|
||||
return fmt.Errorf("plugin icon SVG must not contain processing instructions")
|
||||
}
|
||||
case xml.StartElement:
|
||||
local := strings.ToLower(element.Name.Local)
|
||||
if !rootSeen {
|
||||
if local != "svg" {
|
||||
return fmt.Errorf("plugin icon SVG root element must be svg")
|
||||
}
|
||||
rootSeen = true
|
||||
}
|
||||
if local == "script" || local == "foreignobject" {
|
||||
return fmt.Errorf("plugin icon SVG must not contain %s elements", element.Name.Local)
|
||||
}
|
||||
if local == "style" {
|
||||
styleDepth++
|
||||
}
|
||||
for _, attr := range element.Attr {
|
||||
if attr.Name.Space == "xmlns" || (attr.Name.Space == "" && attr.Name.Local == "xmlns") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(attr.Name.Local), "on") {
|
||||
return fmt.Errorf("plugin icon SVG must not contain event handler attributes")
|
||||
}
|
||||
if svgValueReferencesExternal(attr.Value) {
|
||||
return fmt.Errorf("plugin icon SVG must not reference scripts or external resources")
|
||||
}
|
||||
}
|
||||
case xml.EndElement:
|
||||
if strings.ToLower(element.Name.Local) == "style" && styleDepth > 0 {
|
||||
styleDepth--
|
||||
}
|
||||
case xml.CharData:
|
||||
if styleDepth > 0 && (svgValueReferencesExternal(string(element)) || strings.Contains(strings.ToLower(string(element)), "@import")) {
|
||||
return fmt.Errorf("plugin icon SVG must not reference scripts or external resources")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !rootSeen {
|
||||
return fmt.Errorf("plugin icon SVG root element must be svg")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func svgValueReferencesExternal(value string) bool {
|
||||
compact := strings.Map(func(character rune) rune {
|
||||
if unicode.IsSpace(character) || unicode.IsControl(character) {
|
||||
return -1
|
||||
}
|
||||
return unicode.ToLower(character)
|
||||
}, value)
|
||||
return strings.Contains(compact, "javascript:") || strings.Contains(compact, "http://") || strings.Contains(compact, "https://")
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package jsplugin
|
||||
|
||||
// asciiFold maps only 'A'–'Z' onto 'a'–'z'. Every other byte is unchanged.
|
||||
// Unicode case folding is intentionally not applied: U+212A (KELVIN SIGN)
|
||||
// must not become 'k' and impersonate an ASCII model name.
|
||||
func asciiFold(s string) string {
|
||||
var buf []byte
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if c >= 'A' && c <= 'Z' {
|
||||
if buf == nil {
|
||||
buf = []byte(s)
|
||||
}
|
||||
buf[i] = c + ('a' - 'A')
|
||||
}
|
||||
}
|
||||
if buf == nil {
|
||||
return s
|
||||
}
|
||||
return string(buf)
|
||||
}
|
||||
|
||||
// ASCIIFold is the exported form of asciiFold for consumers outside this package.
|
||||
func ASCIIFold(s string) string {
|
||||
return asciiFold(s)
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package jsplugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
responsesDecodeOnly = `export const protocols = {openai_responses: {
|
||||
decodeRequest: function(ctx) { return ctx; }
|
||||
}};`
|
||||
responsesDecodeEvents = `export const protocols = {openai_responses: {
|
||||
decodeRequest: function(ctx) { return ctx; },
|
||||
renderEvents: function() { return {events: [], state: null, done: false}; }
|
||||
}};`
|
||||
responsesDecodeFinal = `export const protocols = {openai_responses: {
|
||||
decodeRequest: function(ctx) { return ctx; },
|
||||
renderFinal: function(ctx, task) { return task; }
|
||||
}};`
|
||||
responsesDecodeBoth = `export const protocols = {openai_responses: {
|
||||
decodeRequest: function(ctx) { return ctx; },
|
||||
renderEvents: function() { return {events: [], state: null, done: false}; },
|
||||
renderFinal: function(ctx, task) { return task; }
|
||||
}};`
|
||||
videoProtocolExport = `export const protocols = {openai_video: {
|
||||
decodeRequest: function(ctx) { return ctx; },
|
||||
render: function(ctx, task) { return task; }
|
||||
}};
|
||||
export function listArtifacts() { return []; }
|
||||
export function buildContentRequest() { return {}; }`
|
||||
)
|
||||
|
||||
func TestProtocolSupportsLoadErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
protocols string
|
||||
exports string
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "bare string",
|
||||
protocols: `["openai_responses"]`,
|
||||
exports: responsesDecodeBoth,
|
||||
err: `plugin acme protocol "openai_responses" must declare supports; replace the bare string with {name: "openai_responses", supports: [...]} choosing from "stream", "sync", "background"`,
|
||||
},
|
||||
{
|
||||
name: "object without supports",
|
||||
protocols: `[{name: "openai_responses"}]`,
|
||||
exports: responsesDecodeBoth,
|
||||
err: `plugin acme protocol "openai_responses" must declare supports; add supports: [...] choosing from "stream", "sync", "background"`,
|
||||
},
|
||||
{
|
||||
name: "supports sync but only renderEvents",
|
||||
protocols: `[{name: "openai_responses", supports: ["sync"]}]`,
|
||||
exports: responsesDecodeEvents,
|
||||
err: `plugin acme protocol "openai_responses" supports "sync" but does not export protocols.openai_responses.renderFinal; implement it or declare supports: ["stream"]`,
|
||||
},
|
||||
{
|
||||
name: "supports sync with only decodeRequest",
|
||||
protocols: `[{name: "openai_responses", supports: ["sync"]}]`,
|
||||
exports: responsesDecodeOnly,
|
||||
err: `plugin acme protocol "openai_responses" supports "sync" but does not export protocols.openai_responses.renderFinal; implement it`,
|
||||
},
|
||||
{
|
||||
name: "supports stream but also exports renderFinal",
|
||||
protocols: `[{name: "openai_responses", supports: ["stream"]}]`,
|
||||
exports: responsesDecodeBoth,
|
||||
err: `plugin acme protocol "openai_responses" exports protocols.openai_responses.renderFinal but no supported mode uses it; add "sync" or "background" to supports or remove the hook`,
|
||||
},
|
||||
{
|
||||
name: "supports sync and background but also exports renderEvents",
|
||||
protocols: `[{name: "openai_responses", supports: ["sync", "background"]}]`,
|
||||
exports: responsesDecodeBoth,
|
||||
err: `plugin acme protocol "openai_responses" exports protocols.openai_responses.renderEvents but no supported mode uses it; add "stream" to supports or remove the hook`,
|
||||
},
|
||||
{
|
||||
name: "empty supports",
|
||||
protocols: `[{name: "openai_responses", supports: []}]`,
|
||||
exports: responsesDecodeBoth,
|
||||
err: `plugin acme protocol "openai_responses" supports must contain at least one of "stream", "sync", "background"`,
|
||||
},
|
||||
{
|
||||
name: "duplicate supports",
|
||||
protocols: `[{name: "openai_responses", supports: ["stream", "stream"]}]`,
|
||||
exports: responsesDecodeBoth,
|
||||
err: `plugin acme protocol "openai_responses" supports must be unique`,
|
||||
},
|
||||
{
|
||||
name: "retrieve is not a mode",
|
||||
protocols: `[{name: "openai_responses", supports: ["retrieve"]}]`,
|
||||
exports: responsesDecodeBoth,
|
||||
err: `plugin acme protocol "openai_responses" has no mode "retrieve"; retrieval of a created response is always available and is never declared`,
|
||||
},
|
||||
{
|
||||
name: "openai_video forbids supports",
|
||||
protocols: `[{name: "openai_video", supports: ["stream"]}]`,
|
||||
exports: videoProtocolExport,
|
||||
err: `plugin acme protocol "openai_video" does not define modes; supports is not allowed`,
|
||||
},
|
||||
{
|
||||
name: "unknown protocol forbids supports",
|
||||
protocols: `[{name: "openai_custom", supports: ["stream"]}]`,
|
||||
err: `plugin acme protocol "openai_custom" does not define modes; supports is not allowed`,
|
||||
},
|
||||
}
|
||||
for _, testCase := range tests {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
_, err := compileProtocolPlugin(t, "acme", `["model"]`, testCase.protocols, testCase.exports)
|
||||
require.ErrorContains(t, err, testCase.err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtocolSupportsHappyPaths(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
models string
|
||||
protocols string
|
||||
exports string
|
||||
wantProtocols []ProtocolClaim
|
||||
}{
|
||||
{
|
||||
name: "stream only with renderEvents",
|
||||
models: `["model"]`,
|
||||
protocols: `[{name: "openai_responses", supports: ["stream"]}]`,
|
||||
exports: responsesDecodeEvents,
|
||||
wantProtocols: []ProtocolClaim{
|
||||
{Name: "openai_responses", Supports: []string{"stream"}, objectForm: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "sync and background with renderFinal",
|
||||
models: `["model"]`,
|
||||
protocols: `[{name: "openai_responses", supports: ["sync", "background"]}]`,
|
||||
exports: responsesDecodeFinal,
|
||||
wantProtocols: []ProtocolClaim{
|
||||
{Name: "openai_responses", Supports: []string{"sync", "background"}, objectForm: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "all modes normalize to table order",
|
||||
models: `["model"]`,
|
||||
protocols: `[{name: "openai_responses", supports: ["background", "stream", "sync"]}]`,
|
||||
exports: responsesDecodeBoth,
|
||||
wantProtocols: []ProtocolClaim{
|
||||
{Name: "openai_responses", Supports: []string{"stream", "sync", "background"}, objectForm: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "openai_video object without supports",
|
||||
models: `["gpt-5.5", "gpt-5.6"]`,
|
||||
protocols: `[{name: "openai_video", models: ["gpt-5.5"]}]`,
|
||||
exports: videoProtocolExport,
|
||||
wantProtocols: []ProtocolClaim{
|
||||
{Name: "openai_video", Models: []string{"gpt-5.5"}, objectForm: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bare openai_video",
|
||||
models: `["model"]`,
|
||||
protocols: `["openai_video"]`,
|
||||
exports: videoProtocolExport,
|
||||
wantProtocols: []ProtocolClaim{
|
||||
{Name: "openai_video"},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, testCase := range tests {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
plugin, err := compileProtocolPlugin(t, "acme", testCase.models, testCase.protocols, testCase.exports)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, testCase.wantProtocols, plugin.Meta.Protocols)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneMetaDeepCopiesSupports(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
_, err := registry.Register(protocolPluginSource(
|
||||
"acme",
|
||||
`["model"]`,
|
||||
`[{name: "openai_responses", supports: ["stream", "sync", "background"]}]`,
|
||||
responsesDecodeBoth,
|
||||
), Options{})
|
||||
require.NoError(t, err)
|
||||
|
||||
snapshot := registry.Snapshot()
|
||||
require.Len(t, snapshot.Override, 1)
|
||||
require.Len(t, snapshot.Override[0].Protocols, 1)
|
||||
snapshot.Override[0].Protocols[0].Supports[0] = "mutated"
|
||||
|
||||
plugin, ok := registry.Get("acme")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, []string{"stream", "sync", "background"}, plugin.Meta.Protocols[0].Supports)
|
||||
}
|
||||
|
||||
func TestMetaProtocolSupports(t *testing.T) {
|
||||
streamOnly, err := compileProtocolPlugin(t, "acme", `["model"]`,
|
||||
`[{name: "openai_responses", supports: ["stream"]}]`, responsesDecodeEvents)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, streamOnly.Meta.ProtocolSupports("openai_responses", "stream"))
|
||||
assert.False(t, streamOnly.Meta.ProtocolSupports("openai_responses", "sync"))
|
||||
assert.False(t, streamOnly.Meta.ProtocolSupports("openai_responses", "background"))
|
||||
assert.False(t, streamOnly.Meta.ProtocolSupports("openai_responses", "retrieve"))
|
||||
assert.False(t, streamOnly.Meta.ProtocolSupports("openai_video", "stream"))
|
||||
assert.False(t, streamOnly.Meta.ProtocolSupports("missing", "stream"))
|
||||
|
||||
video, err := compileProtocolPlugin(t, "acme-video", `["model"]`, `["openai_video"]`, videoProtocolExport)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, video.Meta.ProtocolSupports("openai_video", "stream"))
|
||||
}
|
||||
|
||||
func TestProtocolClaimMarshalEmitsSupportsInTableOrder(t *testing.T) {
|
||||
plugin, err := compileProtocolPlugin(t, "acme", `["model"]`,
|
||||
`[{name: "openai_responses", supports: ["background", "sync", "stream"]}]`, responsesDecodeBoth)
|
||||
require.NoError(t, err)
|
||||
encoded, err := common.Marshal(plugin.Meta.Protocols[0])
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, `{"name":"openai_responses","supports":["stream","sync","background"]}`, string(encoded))
|
||||
}
|
||||
|
||||
func compileProtocolPlugin(t *testing.T, key, models, protocols, exports string) (*LoadedPlugin, error) {
|
||||
t.Helper()
|
||||
return CompilePlugin(protocolPluginSource(key, models, protocols, exports), Options{})
|
||||
}
|
||||
|
||||
func protocolPluginSource(key, models, protocols, exports string) string {
|
||||
return fmt.Sprintf(`
|
||||
export const meta = {
|
||||
apiVersion: 1, key: %q, name: %q, version: "1.0.0",
|
||||
author: {name: "Test"},
|
||||
models: %s, fetchMode: "per_task",
|
||||
protocols: %s,
|
||||
};
|
||||
export function buildSubmitRequest() { return {}; }
|
||||
export function parseSubmitResponse() { return {}; }
|
||||
export function buildQueryRequest() { return {}; }
|
||||
export function parseTaskResult() { return {}; }
|
||||
%s
|
||||
`, key, key, models, protocols, exports)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,105 @@
|
||||
package jsplugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRegistrySetDisabledFactoryKeysHidesFactoryPlugin(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
_, err := registry.RegisterFactory(routingTestPluginSource(
|
||||
"factory-off",
|
||||
50,
|
||||
`["factory-off-model"]`,
|
||||
`routes: [
|
||||
{method: "POST", path: "/vendor/factory-off", type: "submit", action: "generate", decode: "decodeVideo", render: "videoCreated"}
|
||||
],
|
||||
protocols: [{name: "openai_responses", supports: ["stream", "sync", "background"]}],`,
|
||||
`export const native = {
|
||||
decodeVideo: function(ctx) { return {kind: "submit", model: "factory-off-model", requestBody: ctx.body.value}; },
|
||||
videoCreated: function(ctx, task) { return task; }
|
||||
};
|
||||
`+routingProtocolExport("openai_responses"),
|
||||
), Options{})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, ok := registry.Get("factory-off")
|
||||
require.True(t, ok)
|
||||
generation := registry.Generation()
|
||||
before := generation.Number
|
||||
_, ok = generation.LookupDeclaredRoute("POST", "/vendor/factory-off")
|
||||
require.True(t, ok)
|
||||
_, ok = generation.LookupEndpoint("POST", "/v1/responses", "factory-off-model")
|
||||
require.True(t, ok)
|
||||
|
||||
registry.SetDisabledFactoryKeys([]string{"factory-off"})
|
||||
_, ok = registry.Get("factory-off")
|
||||
assert.False(t, ok)
|
||||
generation = registry.Generation()
|
||||
assert.Greater(t, generation.Number, before)
|
||||
_, ok = generation.LookupDeclaredRoute("POST", "/vendor/factory-off")
|
||||
assert.False(t, ok)
|
||||
_, ok = generation.LookupEndpoint("POST", "/v1/responses", "factory-off-model")
|
||||
assert.False(t, ok)
|
||||
for _, plugin := range generation.Plugins() {
|
||||
assert.NotEqual(t, "factory-off", plugin.Meta.Key)
|
||||
}
|
||||
for _, route := range generation.Routes() {
|
||||
assert.NotEqual(t, "factory-off", route.Plugin.Meta.Key)
|
||||
}
|
||||
|
||||
snapshot := registry.Snapshot()
|
||||
require.Len(t, snapshot.Factory, 1)
|
||||
assert.Equal(t, "factory-off", snapshot.Factory[0].Key)
|
||||
assert.Equal(t, []string{"factory-off"}, snapshot.DisabledFactory)
|
||||
|
||||
registry.SetDisabledFactoryKeys(nil)
|
||||
plugin, ok := registry.Get("factory-off")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "factory-off", plugin.Meta.Key)
|
||||
assert.Empty(t, registry.Snapshot().DisabledFactory)
|
||||
_, ok = registry.Generation().LookupDeclaredRoute("POST", "/vendor/factory-off")
|
||||
assert.True(t, ok)
|
||||
}
|
||||
|
||||
func TestRegistrySetDisabledFactoryKeysLeavesEnabledOverrideServing(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
require.NoError(t, registerTestPlugin(registry, "1.0.0-factory", true))
|
||||
require.NoError(t, registerTestPlugin(registry, "1.0.0-override", false))
|
||||
|
||||
registry.SetDisabledFactoryKeys([]string{"test"})
|
||||
plugin, ok := registry.Get("test")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "1.0.0-override", plugin.Meta.Version)
|
||||
|
||||
snapshot := registry.Snapshot()
|
||||
require.Len(t, snapshot.Factory, 1)
|
||||
assert.Equal(t, "1.0.0-factory", snapshot.Factory[0].Version)
|
||||
assert.Equal(t, []string{"test"}, snapshot.DisabledFactory)
|
||||
}
|
||||
|
||||
func TestRegistrySetDisabledFactoryKeysEmptySetDoesNotBumpGeneration(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
require.NoError(t, registerTestPlugin(registry, "1.0.0-factory", true))
|
||||
before := registry.Generation().Number
|
||||
|
||||
registry.SetDisabledFactoryKeys(nil)
|
||||
assert.Equal(t, before, registry.Generation().Number)
|
||||
registry.SetDisabledFactoryKeys([]string{})
|
||||
assert.Equal(t, before, registry.Generation().Number)
|
||||
registry.SetDisabledFactoryKeys([]string{"", " "})
|
||||
assert.Equal(t, before, registry.Generation().Number)
|
||||
}
|
||||
|
||||
func TestRegistrySnapshotDisabledFactoryIsSorted(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
require.NoError(t, registerTestPlugin(registry, "1.0.0", true))
|
||||
_, err := registry.RegisterFactory(routingTestPluginSource("other", 0, `["other-model"]`, "", ""), Options{})
|
||||
require.NoError(t, err)
|
||||
|
||||
registry.SetDisabledFactoryKeys([]string{"other", "test"})
|
||||
assert.Equal(t, []string{"other", "test"}, registry.Snapshot().DisabledFactory)
|
||||
require.Len(t, registry.Snapshot().Factory, 2)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package jsplugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRegistrySetEnabledHidesFactoryAndOverride(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
_, err := registry.RegisterFactory(routingTestPluginSource(
|
||||
"master-factory",
|
||||
50,
|
||||
`["master-factory-model"]`,
|
||||
`routes: [
|
||||
{method: "POST", path: "/vendor/master-factory", type: "submit", action: "generate", decode: "decodeVideo", render: "videoCreated"}
|
||||
],
|
||||
protocols: [{name: "openai_responses", supports: ["stream", "sync", "background"]}],`,
|
||||
`export const native = {
|
||||
decodeVideo: function(ctx) { return {kind: "submit", model: "master-factory-model", requestBody: ctx.body.value}; },
|
||||
videoCreated: function(ctx, task) { return task; }
|
||||
};
|
||||
`+routingProtocolExport("openai_responses"),
|
||||
), Options{})
|
||||
require.NoError(t, err)
|
||||
_, err = registry.Register(routingTestPluginSource(
|
||||
"master-override",
|
||||
51,
|
||||
`["master-override-model"]`,
|
||||
`routes: [
|
||||
{method: "POST", path: "/vendor/master-override", type: "submit", action: "generate", decode: "decodeVideo", render: "videoCreated"}
|
||||
],
|
||||
protocols: [{name: "openai_responses", supports: ["stream", "sync", "background"]}],`,
|
||||
`export const native = {
|
||||
decodeVideo: function(ctx) { return {kind: "submit", model: "master-override-model", requestBody: ctx.body.value}; },
|
||||
videoCreated: function(ctx, task) { return task; }
|
||||
};
|
||||
`+routingProtocolExport("openai_responses"),
|
||||
), Options{})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, ok := registry.Get("master-factory")
|
||||
require.True(t, ok)
|
||||
_, ok = registry.Get("master-override")
|
||||
require.True(t, ok)
|
||||
before := registry.Generation().Number
|
||||
|
||||
registry.SetEnabled(false)
|
||||
_, ok = registry.Get("master-factory")
|
||||
assert.False(t, ok)
|
||||
_, ok = registry.Get("master-override")
|
||||
assert.False(t, ok)
|
||||
generation := registry.Generation()
|
||||
assert.Greater(t, generation.Number, before)
|
||||
assert.Empty(t, generation.Plugins())
|
||||
assert.Empty(t, generation.Routes())
|
||||
_, ok = generation.LookupDeclaredRoute("POST", "/vendor/master-factory")
|
||||
assert.False(t, ok)
|
||||
_, ok = generation.LookupDeclaredRoute("POST", "/vendor/master-override")
|
||||
assert.False(t, ok)
|
||||
_, ok = generation.LookupEndpoint("POST", "/v1/responses", "master-factory-model")
|
||||
assert.False(t, ok)
|
||||
_, ok = generation.LookupEndpoint("POST", "/v1/responses", "master-override-model")
|
||||
assert.False(t, ok)
|
||||
|
||||
snapshot := registry.Snapshot()
|
||||
require.Len(t, snapshot.Factory, 1)
|
||||
assert.Equal(t, "master-factory", snapshot.Factory[0].Key)
|
||||
require.Len(t, snapshot.Override, 1)
|
||||
assert.Equal(t, "master-override", snapshot.Override[0].Key)
|
||||
}
|
||||
|
||||
func TestRegistryMutationsWhileDisabledDoNotResurrectEndpoints(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
require.NoError(t, registerTestPlugin(registry, "1.0.0-factory", true))
|
||||
registry.SetEnabled(false)
|
||||
_, ok := registry.Get("test")
|
||||
require.False(t, ok)
|
||||
|
||||
require.NoError(t, registerTestPlugin(registry, "1.0.0-override", false))
|
||||
registry.SetDisabledFactoryKeys([]string{"other"})
|
||||
_, ok = registry.Get("test")
|
||||
assert.False(t, ok)
|
||||
assert.Empty(t, registry.Generation().Plugins())
|
||||
|
||||
override := registry.OverridePlugins()
|
||||
require.Contains(t, override, "test")
|
||||
require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{override["test"]}))
|
||||
_, ok = registry.Get("test")
|
||||
assert.False(t, ok)
|
||||
assert.Empty(t, registry.Generation().Plugins())
|
||||
}
|
||||
|
||||
func TestRegistrySetEnabledTrueRestoresFactoryAndOverride(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
require.NoError(t, registerTestPlugin(registry, "1.0.0-factory", true))
|
||||
registry.SetEnabled(false)
|
||||
require.NoError(t, registerTestPlugin(registry, "1.0.0-override", false))
|
||||
_, ok := registry.Get("test")
|
||||
require.False(t, ok)
|
||||
|
||||
registry.SetEnabled(true)
|
||||
plugin, ok := registry.Get("test")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "1.0.0-override", plugin.Meta.Version)
|
||||
snapshot := registry.Snapshot()
|
||||
require.Len(t, snapshot.Factory, 1)
|
||||
require.Len(t, snapshot.Override, 1)
|
||||
}
|
||||
|
||||
func TestRegistrySetEnabledNoOpDoesNotBumpGeneration(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
require.NoError(t, registerTestPlugin(registry, "1.0.0", true))
|
||||
before := registry.Generation().Number
|
||||
|
||||
registry.SetEnabled(true)
|
||||
assert.Equal(t, before, registry.Generation().Number)
|
||||
}
|
||||
|
||||
func TestRegistryMasterSwitchIsOrthogonalToLayerFlags(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
require.NoError(t, registerTestPlugin(registry, "1.0.0-factory", true))
|
||||
|
||||
registry.SetDisabledFactoryKeys([]string{"test"})
|
||||
_, ok := registry.Get("test")
|
||||
assert.False(t, ok)
|
||||
|
||||
registry.SetEnabled(false)
|
||||
_, ok = registry.Get("test")
|
||||
assert.False(t, ok)
|
||||
|
||||
registry.SetEnabled(true)
|
||||
_, ok = registry.Get("test")
|
||||
assert.False(t, ok)
|
||||
|
||||
registry.SetDisabledFactoryKeys(nil)
|
||||
plugin, ok := registry.Get("test")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "1.0.0-factory", plugin.Meta.Version)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,43 @@
|
||||
package jsplugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ValidateRequestURL prevents plugins from directing a channel credential to
|
||||
// hosts other than the configured base URL or an administrator-approved host.
|
||||
func ValidateRequestURL(requestURL, baseURL string, allowedHosts []string) error {
|
||||
request, err := url.Parse(requestURL)
|
||||
if err != nil || request.Scheme == "" || request.Host == "" {
|
||||
return fmt.Errorf("plugin request URL must be absolute")
|
||||
}
|
||||
base, err := url.Parse(baseURL)
|
||||
if err != nil || base.Host == "" {
|
||||
return fmt.Errorf("channel base URL is invalid")
|
||||
}
|
||||
requestHost := canonicalHost(request)
|
||||
if requestHost == canonicalHost(base) {
|
||||
return nil
|
||||
}
|
||||
for _, allowed := range allowedHosts {
|
||||
// Parse with the request scheme so "host:443" matches an https request
|
||||
// the same way an explicit default port on the request URL does.
|
||||
allowedURL, parseErr := url.Parse(request.Scheme + "://" + strings.TrimSpace(allowed))
|
||||
if parseErr == nil && requestHost == canonicalHost(allowedURL) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("plugin request host %q is not allowed", request.Host)
|
||||
}
|
||||
|
||||
func canonicalHost(value *url.URL) string {
|
||||
host := strings.ToLower(value.Hostname())
|
||||
port := value.Port()
|
||||
if port == "" || port == "80" && value.Scheme == "http" || port == "443" && value.Scheme == "https" {
|
||||
return host
|
||||
}
|
||||
return net.JoinHostPort(host, port)
|
||||
}
|
||||
@@ -0,0 +1,963 @@
|
||||
package jsplugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
)
|
||||
|
||||
type RouteType string
|
||||
|
||||
const (
|
||||
RouteTypeSubmit RouteType = "submit"
|
||||
RouteTypeQuery RouteType = "query"
|
||||
RouteTypeDynamic RouteType = "dynamic"
|
||||
)
|
||||
|
||||
type Route struct {
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Type RouteType `json:"type"`
|
||||
Action string `json:"action,omitempty"`
|
||||
Decode string `json:"decode,omitempty"`
|
||||
Render string `json:"render,omitempty"`
|
||||
TaskIDParam string `json:"taskIdParam,omitempty"`
|
||||
// Models restricts this route to the listed models. The host matches the
|
||||
// canonical top-level "model" body field before any JS hook runs; empty
|
||||
// means unrestricted. Must be a subset of meta.models.
|
||||
Models []string `json:"models,omitempty"`
|
||||
}
|
||||
|
||||
// ProtocolClaim is one entry of meta.protocols. Models narrows the protocol's
|
||||
// endpoint bindings to a subset of meta.models; empty binds every model.
|
||||
// Supports names the request forms a mode-bearing protocol accepts; decode
|
||||
// and normalize rewrite it into host-table order.
|
||||
type ProtocolClaim struct {
|
||||
Name string `json:"name"`
|
||||
Models []string `json:"models,omitempty"`
|
||||
Supports []string `json:"supports,omitempty"`
|
||||
objectForm bool
|
||||
}
|
||||
|
||||
// ProtocolMode is one client request form a host protocol operation accepts
|
||||
// and the plugin hook that implements it.
|
||||
type ProtocolMode struct {
|
||||
Name string
|
||||
Hook string
|
||||
}
|
||||
|
||||
type BodyKind string
|
||||
|
||||
const (
|
||||
BodyNone BodyKind = "none"
|
||||
BodyJSON BodyKind = "json"
|
||||
BodyForm BodyKind = "form"
|
||||
BodyMultipart BodyKind = "multipart"
|
||||
)
|
||||
|
||||
type HostProtocolOperation struct {
|
||||
Name string
|
||||
Methods []string
|
||||
Path string
|
||||
BodyKinds []BodyKind
|
||||
ModelField string
|
||||
RequiredProtocolMembers []string
|
||||
Modes []ProtocolMode
|
||||
RequiredDriverHooks []string
|
||||
}
|
||||
|
||||
type HostProtocolDefinition struct {
|
||||
Name string
|
||||
Operations []HostProtocolOperation
|
||||
}
|
||||
|
||||
var hostProtocols = []HostProtocolDefinition{
|
||||
{Name: "openai_responses", Operations: []HostProtocolOperation{
|
||||
{Name: "create", Methods: []string{http.MethodPost}, Path: "/v1/responses", BodyKinds: []BodyKind{BodyJSON}, ModelField: "model", RequiredProtocolMembers: []string{"decodeRequest"}, Modes: []ProtocolMode{{Name: "stream", Hook: "renderEvents"}, {Name: "sync", Hook: "renderFinal"}, {Name: "background", Hook: "renderFinal"}}},
|
||||
{Name: "retrieve", Methods: []string{http.MethodGet}, Path: "/v1/responses/:response_id", BodyKinds: []BodyKind{BodyNone}},
|
||||
}},
|
||||
{Name: "openai_video", Operations: []HostProtocolOperation{
|
||||
{Name: "create", Methods: []string{http.MethodPost}, Path: "/v1/videos", BodyKinds: []BodyKind{BodyJSON, BodyMultipart}, ModelField: "model", RequiredProtocolMembers: []string{"decodeRequest"}},
|
||||
{Name: "retrieve", Methods: []string{http.MethodGet}, Path: "/v1/videos/:task_id", BodyKinds: []BodyKind{BodyNone}, RequiredProtocolMembers: []string{"render"}},
|
||||
{Name: "content", Methods: []string{http.MethodGet, http.MethodHead}, Path: "/v1/videos/:task_id/content", BodyKinds: []BodyKind{BodyNone}, RequiredDriverHooks: []string{"listArtifacts", "buildContentRequest"}},
|
||||
}},
|
||||
}
|
||||
|
||||
func HostProtocol(name string) (HostProtocolDefinition, bool) {
|
||||
for _, definition := range hostProtocols {
|
||||
if definition.Name == name {
|
||||
return definition, true
|
||||
}
|
||||
}
|
||||
return HostProtocolDefinition{}, false
|
||||
}
|
||||
|
||||
func HostProtocols() []HostProtocolDefinition {
|
||||
definitions := make([]HostProtocolDefinition, len(hostProtocols))
|
||||
for index, definition := range hostProtocols {
|
||||
definitions[index] = definition
|
||||
definitions[index].Operations = append([]HostProtocolOperation(nil), definition.Operations...)
|
||||
for operationIndex := range definitions[index].Operations {
|
||||
operation := &definitions[index].Operations[operationIndex]
|
||||
operation.Methods = append([]string(nil), operation.Methods...)
|
||||
operation.BodyKinds = append([]BodyKind(nil), operation.BodyKinds...)
|
||||
operation.RequiredProtocolMembers = append([]string(nil), operation.RequiredProtocolMembers...)
|
||||
operation.Modes = append([]ProtocolMode(nil), operation.Modes...)
|
||||
operation.RequiredDriverHooks = append([]string(nil), operation.RequiredDriverHooks...)
|
||||
}
|
||||
}
|
||||
return definitions
|
||||
}
|
||||
|
||||
// DefinedModes returns each distinct mode on the protocol in host-table order.
|
||||
func (d HostProtocolDefinition) DefinedModes() []ProtocolMode {
|
||||
seen := make(map[string]struct{})
|
||||
modes := make([]ProtocolMode, 0)
|
||||
for _, operation := range d.Operations {
|
||||
for _, mode := range operation.Modes {
|
||||
if _, exists := seen[mode.Name]; exists {
|
||||
continue
|
||||
}
|
||||
seen[mode.Name] = struct{}{}
|
||||
modes = append(modes, mode)
|
||||
}
|
||||
}
|
||||
return modes
|
||||
}
|
||||
|
||||
func orderProtocolSupports(protocol string, supports []string) []string {
|
||||
if len(supports) == 0 {
|
||||
return supports
|
||||
}
|
||||
definition, ok := HostProtocol(protocol)
|
||||
if !ok {
|
||||
return supports
|
||||
}
|
||||
rank := make(map[string]int)
|
||||
for index, mode := range definition.DefinedModes() {
|
||||
rank[mode.Name] = index
|
||||
}
|
||||
ordered := append([]string(nil), supports...)
|
||||
slices.SortStableFunc(ordered, func(left, right string) int {
|
||||
leftRank, leftKnown := rank[left]
|
||||
rightRank, rightKnown := rank[right]
|
||||
switch {
|
||||
case leftKnown && rightKnown:
|
||||
if leftRank < rightRank {
|
||||
return -1
|
||||
}
|
||||
if leftRank > rightRank {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
case leftKnown:
|
||||
return -1
|
||||
case rightKnown:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
})
|
||||
return ordered
|
||||
}
|
||||
|
||||
func LookupHostProtocolOperation(method, path string) (string, HostProtocolOperation, bool) {
|
||||
method = strings.ToUpper(strings.TrimSpace(method))
|
||||
for _, definition := range hostProtocols {
|
||||
for _, operation := range definition.Operations {
|
||||
if operation.Path != path || operation.ModelField == "" {
|
||||
continue
|
||||
}
|
||||
if slices.Contains(operation.Methods, method) {
|
||||
return definition.Name, operation, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", HostProtocolOperation{}, false
|
||||
}
|
||||
|
||||
type RouteBinding struct {
|
||||
Plugin *LoadedPlugin
|
||||
Route Route
|
||||
}
|
||||
|
||||
type ProtocolBinding struct {
|
||||
Plugin *LoadedPlugin
|
||||
Protocol string
|
||||
Operation HostProtocolOperation
|
||||
Model string
|
||||
}
|
||||
|
||||
const (
|
||||
ContextKeyPinnedPlugin = "task_plugin_pinned_plugin"
|
||||
ContextKeyPinnedRoute = "task_plugin_pinned_route"
|
||||
ContextKeyPinnedEndpoint = "task_plugin_pinned_endpoint"
|
||||
ContextKeyRouteRequest = "task_plugin_route_request"
|
||||
ContextKeyProtocolRequest = "task_plugin_protocol_request"
|
||||
)
|
||||
|
||||
type PinnedPlugin struct {
|
||||
Generation *RoutingGeneration
|
||||
Plugin *LoadedPlugin
|
||||
}
|
||||
|
||||
type PinnedRoute struct {
|
||||
Generation *RoutingGeneration
|
||||
Plugin *LoadedPlugin
|
||||
Route Route
|
||||
}
|
||||
|
||||
// PinnedEndpoint carries the exact generation and endpoint candidates selected
|
||||
// before distribution. Plugin initially names the deterministic request parser;
|
||||
// distribution may rebind it to another candidate from the same generation
|
||||
// when multiple legacy providers expose the same model.
|
||||
type PinnedEndpoint struct {
|
||||
Generation *RoutingGeneration
|
||||
Plugin *LoadedPlugin
|
||||
Protocol string
|
||||
Operation HostProtocolOperation
|
||||
Model string
|
||||
MappedModel string
|
||||
Candidates []ProtocolBinding
|
||||
}
|
||||
|
||||
// RouteRequestContext is the canonical request view exposed to declarative
|
||||
// routing hooks. RequestBody contains decoded JSON or multipart text fields;
|
||||
// raw binary and multipart file bytes remain host-owned.
|
||||
type RouteRequestContext struct {
|
||||
Path string `json:"path"`
|
||||
Method string `json:"method"`
|
||||
Params map[string]string `json:"params"`
|
||||
Query map[string][]string `json:"query"`
|
||||
Body any `json:"body"`
|
||||
Files []map[string]any `json:"-"`
|
||||
RequestBody any `json:"-"`
|
||||
}
|
||||
|
||||
func (r RouteRequestContext) JSValue() map[string]any {
|
||||
params := make(map[string]string, len(r.Params))
|
||||
maps.Copy(params, r.Params)
|
||||
query := make(map[string][]string, len(r.Query))
|
||||
for key, values := range r.Query {
|
||||
query[key] = append([]string(nil), values...)
|
||||
}
|
||||
return map[string]any{
|
||||
"path": r.Path,
|
||||
"method": r.Method,
|
||||
"params": params,
|
||||
"query": query,
|
||||
"body": clonePluginRequestValue(r.Body),
|
||||
}
|
||||
}
|
||||
|
||||
func clonePluginRequestValue(value any) any {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
cloned := make(map[string]any, len(typed))
|
||||
for key, item := range typed {
|
||||
cloned[key] = clonePluginRequestValue(item)
|
||||
}
|
||||
return cloned
|
||||
case []any:
|
||||
cloned := make([]any, len(typed))
|
||||
for index, item := range typed {
|
||||
cloned[index] = clonePluginRequestValue(item)
|
||||
}
|
||||
return cloned
|
||||
case []string:
|
||||
return append([]string(nil), typed...)
|
||||
case map[string][]string:
|
||||
cloned := make(map[string][]string, len(typed))
|
||||
for key, values := range typed {
|
||||
cloned[key] = append([]string(nil), values...)
|
||||
}
|
||||
return cloned
|
||||
case []map[string]any:
|
||||
cloned := make([]map[string]any, len(typed))
|
||||
for index, item := range typed {
|
||||
cloned[index] = clonePluginRequestValue(item).(map[string]any)
|
||||
}
|
||||
return cloned
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
type ProtocolRequestContext struct {
|
||||
RouteRequestContext
|
||||
Protocol string `json:"protocol"`
|
||||
Operation string `json:"operation"`
|
||||
Model string `json:"model"`
|
||||
// UpstreamModel is the declared machine identity when Model is a
|
||||
// channel-mapping alias; empty otherwise. Decode hooks that key rate
|
||||
// tables or request shaping by model must use it over Model.
|
||||
UpstreamModel string `json:"upstreamModel,omitempty"`
|
||||
Stream bool `json:"stream"`
|
||||
}
|
||||
|
||||
func (p ProtocolRequestContext) JSValue() map[string]any {
|
||||
value := p.RouteRequestContext.JSValue()
|
||||
value["protocol"] = p.Protocol
|
||||
value["operation"] = p.Operation
|
||||
value["model"] = p.Model
|
||||
if p.UpstreamModel != "" {
|
||||
value["upstreamModel"] = p.UpstreamModel
|
||||
}
|
||||
value["stream"] = p.Stream
|
||||
return value
|
||||
}
|
||||
|
||||
// SupportsHostProtocol reports whether the current host release has a
|
||||
// concrete wire/state machine for an otherwise valid manifest endpoint.
|
||||
func SupportsHostProtocol(protocol string) bool { _, ok := HostProtocol(protocol); return ok }
|
||||
|
||||
// RoutingGeneration is an immutable, request-pinnable view of all effective
|
||||
// plugins and their deterministic routing indexes.
|
||||
type RoutingGeneration struct {
|
||||
Number uint64
|
||||
PublishedAt time.Time
|
||||
|
||||
byKey map[string]*LoadedPlugin
|
||||
byModel map[string]*LoadedPlugin
|
||||
canonicalModelByFold map[string]string
|
||||
byChannelType map[int]*LoadedPlugin
|
||||
routeIndex map[string]RouteBinding
|
||||
protocolIndex map[string][]ProtocolBinding
|
||||
plugins []*LoadedPlugin
|
||||
routes []RouteBinding
|
||||
runtime http.Handler
|
||||
retainCurrent map[string]struct{}
|
||||
}
|
||||
|
||||
var (
|
||||
routeMethodPattern = regexp.MustCompile(`^(GET|POST|PUT|PATCH|DELETE)$`)
|
||||
pathNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||
staticSegment = regexp.MustCompile(`^[A-Za-z0-9._~-]+$`)
|
||||
memberNamePattern = regexp.MustCompile(`^[A-Za-z_$][A-Za-z0-9_$]*$`)
|
||||
)
|
||||
|
||||
var reservedRouteNamespaces = []string{
|
||||
"/api",
|
||||
"/assets",
|
||||
"/setup",
|
||||
"/v1/tasks",
|
||||
"/console",
|
||||
"/login",
|
||||
"/forbidden",
|
||||
"/sign-in",
|
||||
"/sign-up",
|
||||
"/forgot-password",
|
||||
"/oauth",
|
||||
"/otp",
|
||||
"/register",
|
||||
"/reset",
|
||||
"/privacy-policy",
|
||||
"/user-agreement",
|
||||
"/about",
|
||||
"/pricing",
|
||||
"/rankings",
|
||||
"/user",
|
||||
"/401",
|
||||
"/403",
|
||||
"/404",
|
||||
"/500",
|
||||
"/503",
|
||||
"/chat2link",
|
||||
"/system-settings",
|
||||
"/channels",
|
||||
"/chat",
|
||||
"/dashboard",
|
||||
"/errors",
|
||||
"/keys",
|
||||
"/models",
|
||||
"/playground",
|
||||
"/profile",
|
||||
"/redemption-codes",
|
||||
"/subscriptions",
|
||||
"/system-info",
|
||||
"/task-plugins",
|
||||
"/usage-logs",
|
||||
"/users",
|
||||
"/wallet",
|
||||
}
|
||||
|
||||
func (g *RoutingGeneration) Get(key string) (*LoadedPlugin, bool) {
|
||||
if g == nil {
|
||||
return nil, false
|
||||
}
|
||||
plugin, ok := g.byKey[key]
|
||||
return plugin, ok
|
||||
}
|
||||
|
||||
func (g *RoutingGeneration) GetByChannelType(channelType int) (*LoadedPlugin, bool) {
|
||||
if g == nil || channelType == 0 || channelType == constant.ChannelTypeTaskPlugin {
|
||||
return nil, false
|
||||
}
|
||||
plugin, ok := g.byChannelType[channelType]
|
||||
return plugin, ok
|
||||
}
|
||||
|
||||
// GetByModel returns the deterministic effective plugin metadata used for a
|
||||
// model-level host concern such as billing. When multiple providers expose the
|
||||
// same model name, the first plugin in generation order owns that shared
|
||||
// metadata view.
|
||||
func (g *RoutingGeneration) GetByModel(model string) (*LoadedPlugin, bool) {
|
||||
if g == nil || model == "" {
|
||||
return nil, false
|
||||
}
|
||||
plugin, ok := g.byModel[model]
|
||||
return plugin, ok
|
||||
}
|
||||
|
||||
// CanonicalModel returns the declared spelling for model. An exact byModel
|
||||
// hit wins and returns the input unchanged; otherwise the ASCII-folded
|
||||
// index is consulted. Miss and nil-receiver return ("", false).
|
||||
func (g *RoutingGeneration) CanonicalModel(model string) (string, bool) {
|
||||
if g == nil || model == "" {
|
||||
return "", false
|
||||
}
|
||||
if _, ok := g.byModel[model]; ok {
|
||||
return model, true
|
||||
}
|
||||
declared, ok := g.canonicalModelByFold[asciiFold(model)]
|
||||
return declared, ok
|
||||
}
|
||||
|
||||
// LookupDeclaredRoute resolves a manifest path declaration. It does not match
|
||||
// an incoming concrete URL; runtime matching is delegated to Gin.
|
||||
func (g *RoutingGeneration) LookupDeclaredRoute(method, path string) (RouteBinding, bool) {
|
||||
if g == nil {
|
||||
return RouteBinding{}, false
|
||||
}
|
||||
normalizedMethod, err := normalizeRouteMethod(method)
|
||||
if err != nil {
|
||||
return RouteBinding{}, false
|
||||
}
|
||||
shape, err := routePathShape(path)
|
||||
if err != nil {
|
||||
return RouteBinding{}, false
|
||||
}
|
||||
binding, ok := g.routeIndex[normalizedMethod+" "+shape]
|
||||
return binding, ok
|
||||
}
|
||||
|
||||
func (g *RoutingGeneration) LookupEndpoint(method, path, model string) (ProtocolBinding, bool) {
|
||||
if g == nil {
|
||||
return ProtocolBinding{}, false
|
||||
}
|
||||
normalizedMethod, err := normalizeRouteMethod(method)
|
||||
if err != nil {
|
||||
return ProtocolBinding{}, false
|
||||
}
|
||||
bindings := g.protocolIndex[endpointIndexKey(normalizedMethod, path, model)]
|
||||
if len(bindings) == 0 {
|
||||
return ProtocolBinding{}, false
|
||||
}
|
||||
return bindings[0], true
|
||||
}
|
||||
|
||||
// LookupEndpointCandidates returns every legacy provider implementation that
|
||||
// can serve one shared model endpoint. Candidate order is deterministic and
|
||||
// the first binding is the parser used before channel distribution.
|
||||
func (g *RoutingGeneration) LookupEndpointCandidates(method, path, model string) []ProtocolBinding {
|
||||
if g == nil {
|
||||
return nil
|
||||
}
|
||||
normalizedMethod, err := normalizeRouteMethod(method)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
bindings := g.protocolIndex[endpointIndexKey(normalizedMethod, path, model)]
|
||||
return append([]ProtocolBinding(nil), bindings...)
|
||||
}
|
||||
|
||||
func (g *RoutingGeneration) Plugins() []*LoadedPlugin {
|
||||
if g == nil {
|
||||
return nil
|
||||
}
|
||||
return append([]*LoadedPlugin(nil), g.plugins...)
|
||||
}
|
||||
|
||||
func (g *RoutingGeneration) Routes() []RouteBinding {
|
||||
if g == nil {
|
||||
return nil
|
||||
}
|
||||
return append([]RouteBinding(nil), g.routes...)
|
||||
}
|
||||
|
||||
// RuntimeHandler is the inner router built for this exact generation. It is
|
||||
// published in the same atomic pointer as the routing indexes.
|
||||
func (g *RoutingGeneration) RuntimeHandler() http.Handler {
|
||||
if g == nil {
|
||||
return nil
|
||||
}
|
||||
return g.runtime
|
||||
}
|
||||
|
||||
func (g *RoutingGeneration) RetainsIncumbent(key string) bool {
|
||||
if g == nil {
|
||||
return false
|
||||
}
|
||||
_, retained := g.retainCurrent[key]
|
||||
return retained
|
||||
}
|
||||
|
||||
// RebuildWithPlugins creates a generation from the supplied exact plugin
|
||||
// pointers. It is used when a rejected hot update must retain the incumbent
|
||||
// runtime object for that key.
|
||||
func (g *RoutingGeneration) RebuildWithPlugins(plugins []*LoadedPlugin) (*RoutingGeneration, error) {
|
||||
if g == nil {
|
||||
return nil, fmt.Errorf("cannot rebuild a nil routing generation")
|
||||
}
|
||||
byKey := make(map[string]*LoadedPlugin, len(plugins))
|
||||
for _, plugin := range plugins {
|
||||
if plugin == nil {
|
||||
return nil, fmt.Errorf("cannot rebuild routing generation with a nil plugin")
|
||||
}
|
||||
if _, exists := g.byKey[plugin.Meta.Key]; !exists {
|
||||
return nil, fmt.Errorf("plugin %q is not present in routing generation %d", plugin.Meta.Key, g.Number)
|
||||
}
|
||||
if _, duplicate := byKey[plugin.Meta.Key]; duplicate {
|
||||
return nil, fmt.Errorf("plugin %q appears more than once in routing generation rebuild", plugin.Meta.Key)
|
||||
}
|
||||
byKey[plugin.Meta.Key] = plugin
|
||||
}
|
||||
rebuilt, err := buildRoutingGeneration(byKey, nil, g.Number)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rebuilt.PublishedAt = g.PublishedAt
|
||||
rebuilt.retainCurrent = cloneStringSet(g.retainCurrent)
|
||||
return rebuilt, nil
|
||||
}
|
||||
|
||||
// WithRuntime returns a shallow immutable copy carrying the prepared inner
|
||||
// handler. Callers use it before publication; published generations must not
|
||||
// be mutated.
|
||||
func (g *RoutingGeneration) WithRuntime(handler http.Handler) *RoutingGeneration {
|
||||
if g == nil {
|
||||
return nil
|
||||
}
|
||||
prepared := *g
|
||||
prepared.runtime = handler
|
||||
return &prepared
|
||||
}
|
||||
|
||||
func cloneStringSet(source map[string]struct{}) map[string]struct{} {
|
||||
if len(source) == 0 {
|
||||
return nil
|
||||
}
|
||||
clone := make(map[string]struct{}, len(source))
|
||||
for key := range source {
|
||||
clone[key] = struct{}{}
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
func normalizeRouteMethod(method string) (string, error) {
|
||||
method = strings.ToUpper(strings.TrimSpace(method))
|
||||
if !routeMethodPattern.MatchString(method) {
|
||||
return "", fmt.Errorf("plugin route method %q is not supported", method)
|
||||
}
|
||||
return method, nil
|
||||
}
|
||||
|
||||
// NormalizeRoutePath validates the canonical path syntax used by plugin route
|
||||
// declarations. A trailing slash is allowed and remains significant.
|
||||
func NormalizeRoutePath(routePath string) (string, error) {
|
||||
if routePath == "" || routePath[0] != '/' {
|
||||
return "", fmt.Errorf("plugin route path must start with /")
|
||||
}
|
||||
if routePath == "/" {
|
||||
return "", fmt.Errorf("plugin route path / is reserved")
|
||||
}
|
||||
if strings.ContainsAny(routePath, "?#%") {
|
||||
return "", fmt.Errorf("plugin route path %q must not contain a query, fragment, or percent-encoding", routePath)
|
||||
}
|
||||
if strings.Contains(routePath, "//") {
|
||||
return "", fmt.Errorf("plugin route path %q must not contain empty segments", routePath)
|
||||
}
|
||||
|
||||
segments := strings.Split(strings.TrimPrefix(routePath, "/"), "/")
|
||||
seenNames := make(map[string]struct{})
|
||||
for index, segment := range segments {
|
||||
if segment == "" && index == len(segments)-1 {
|
||||
continue
|
||||
}
|
||||
if segment == "." || segment == ".." {
|
||||
return "", fmt.Errorf("plugin route path %q must not contain dot segments", routePath)
|
||||
}
|
||||
if after, ok := strings.CutPrefix(segment, ":"); ok {
|
||||
name := after
|
||||
if !pathNamePattern.MatchString(name) {
|
||||
return "", fmt.Errorf("plugin route path %q has invalid parameter %q", routePath, segment)
|
||||
}
|
||||
if _, exists := seenNames[name]; exists {
|
||||
return "", fmt.Errorf("plugin route path %q repeats parameter %q", routePath, name)
|
||||
}
|
||||
seenNames[name] = struct{}{}
|
||||
continue
|
||||
}
|
||||
if after, ok := strings.CutPrefix(segment, "*"); ok {
|
||||
name := after
|
||||
if index != len(segments)-1 || !pathNamePattern.MatchString(name) {
|
||||
return "", fmt.Errorf("plugin route path %q has an invalid catch-all segment", routePath)
|
||||
}
|
||||
if _, exists := seenNames[name]; exists {
|
||||
return "", fmt.Errorf("plugin route path %q repeats parameter %q", routePath, name)
|
||||
}
|
||||
seenNames[name] = struct{}{}
|
||||
continue
|
||||
}
|
||||
if !staticSegment.MatchString(segment) {
|
||||
return "", fmt.Errorf("plugin route path %q has invalid segment %q", routePath, segment)
|
||||
}
|
||||
}
|
||||
return routePath, nil
|
||||
}
|
||||
|
||||
func routePathShape(routePath string) (string, error) {
|
||||
normalized, err := NormalizeRoutePath(routePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
segments := strings.Split(strings.TrimPrefix(normalized, "/"), "/")
|
||||
for index, segment := range segments {
|
||||
if strings.HasPrefix(segment, ":") {
|
||||
segments[index] = ":"
|
||||
} else if strings.HasPrefix(segment, "*") {
|
||||
segments[index] = "*"
|
||||
}
|
||||
}
|
||||
return "/" + strings.Join(segments, "/"), nil
|
||||
}
|
||||
|
||||
func endpointIndexKey(method, path, model string) string {
|
||||
return method + "\x00" + path + "\x00" + model
|
||||
}
|
||||
|
||||
func intersectingReservedNamespace(routePath string) (string, bool) {
|
||||
for _, namespace := range reservedRouteNamespaces {
|
||||
if routePatternIntersectsNamespace(routePath, namespace) {
|
||||
return namespace, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func routePatternIntersectsNamespace(routePath, namespace string) bool {
|
||||
routeSegments := strings.Split(strings.Trim(strings.TrimPrefix(routePath, "/"), "/"), "/")
|
||||
namespaceSegments := strings.Split(strings.TrimPrefix(namespace, "/"), "/")
|
||||
for index, namespaceSegment := range namespaceSegments {
|
||||
if index >= len(routeSegments) {
|
||||
return false
|
||||
}
|
||||
routeSegment := routeSegments[index]
|
||||
if strings.HasPrefix(routeSegment, "*") {
|
||||
return true
|
||||
}
|
||||
if !strings.HasPrefix(routeSegment, ":") && routeSegment != namespaceSegment {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validateRoute(route *Route) error {
|
||||
if route.Method != strings.ToUpper(strings.TrimSpace(route.Method)) {
|
||||
return fmt.Errorf("plugin route method %q must use canonical uppercase spelling", route.Method)
|
||||
}
|
||||
method, err := normalizeRouteMethod(route.Method)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
route.Method = method
|
||||
route.Path, err = NormalizeRoutePath(route.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if namespace, reserved := intersectingReservedNamespace(route.Path); reserved {
|
||||
return fmt.Errorf("plugin route path %q intersects reserved namespace %s", route.Path, namespace)
|
||||
}
|
||||
switch route.Type {
|
||||
case RouteTypeSubmit:
|
||||
if route.Decode == "" || route.Render == "" || route.TaskIDParam != "" {
|
||||
return fmt.Errorf("submit route %s %s must declare decode and render and must not declare taskIdParam", route.Method, route.Path)
|
||||
}
|
||||
case RouteTypeQuery:
|
||||
if route.Decode != "" || strings.TrimSpace(route.Render) == "" {
|
||||
return fmt.Errorf("query route %s %s must declare render and must not declare decode", route.Method, route.Path)
|
||||
}
|
||||
if route.Action != "" {
|
||||
return fmt.Errorf("query route %s %s must not declare action", route.Method, route.Path)
|
||||
}
|
||||
if route.TaskIDParam == "" {
|
||||
route.TaskIDParam = "task_id"
|
||||
}
|
||||
if !pathNamePattern.MatchString(route.TaskIDParam) {
|
||||
return fmt.Errorf("query route %s %s has invalid taskIdParam %q", route.Method, route.Path, route.TaskIDParam)
|
||||
}
|
||||
if !pathHasParameter(route.Path, route.TaskIDParam) {
|
||||
return fmt.Errorf("query route %s %s must contain :%s", route.Method, route.Path, route.TaskIDParam)
|
||||
}
|
||||
case RouteTypeDynamic:
|
||||
if route.Decode == "" || route.Render == "" || route.TaskIDParam != "" {
|
||||
return fmt.Errorf("dynamic route %s %s must declare decode and render and must not declare taskIdParam", route.Method, route.Path)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("plugin route %s %s has unsupported type %q", route.Method, route.Path, route.Type)
|
||||
}
|
||||
if route.Decode != "" && !memberNamePattern.MatchString(route.Decode) {
|
||||
return fmt.Errorf("plugin route %s %s has invalid decode %q", route.Method, route.Path, route.Decode)
|
||||
}
|
||||
if route.Render != "" && !memberNamePattern.MatchString(route.Render) {
|
||||
return fmt.Errorf("plugin route %s %s has invalid render %q", route.Method, route.Path, route.Render)
|
||||
}
|
||||
if strings.TrimSpace(route.Action) != route.Action {
|
||||
return fmt.Errorf("plugin route %s %s action must not have surrounding whitespace", route.Method, route.Path)
|
||||
}
|
||||
if len(route.Models) > 0 {
|
||||
if route.Type == RouteTypeQuery {
|
||||
return fmt.Errorf("query route %s %s must not declare models", route.Method, route.Path)
|
||||
}
|
||||
if err := validateModelScope(route.Models, fmt.Sprintf("route %s %s", route.Method, route.Path)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateModelScope enforces the shared rules for route.models and
|
||||
// per-protocol models entries: non-empty canonical names, no duplicates.
|
||||
func validateModelScope(models []string, subject string) error {
|
||||
seen := make(map[string]struct{}, len(models))
|
||||
for _, model := range models {
|
||||
if strings.TrimSpace(model) == "" || strings.TrimSpace(model) != model {
|
||||
return fmt.Errorf("plugin %s models must contain non-empty canonical names", subject)
|
||||
}
|
||||
folded := asciiFold(model)
|
||||
if _, duplicate := seen[folded]; duplicate {
|
||||
return fmt.Errorf("plugin %s models must be unique case-insensitively", subject)
|
||||
}
|
||||
seen[folded] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pathHasParameter(routePath, name string) bool {
|
||||
return slices.Contains(strings.Split(routePath, "/"), ":"+name)
|
||||
}
|
||||
|
||||
func ResolveRouteAction(route Route, resolvedAction string) string {
|
||||
if strings.TrimSpace(resolvedAction) != "" {
|
||||
return resolvedAction
|
||||
}
|
||||
return route.Action
|
||||
}
|
||||
|
||||
func buildRoutingGeneration(factory, override map[string]*LoadedPlugin, number uint64) (*RoutingGeneration, error) {
|
||||
effective := effectivePlugins(factory, override)
|
||||
return buildRoutingGenerationFromPlugins(effective, number)
|
||||
}
|
||||
|
||||
func buildRoutingGenerationAdmitting(
|
||||
factory, override map[string]*LoadedPlugin,
|
||||
number uint64,
|
||||
current *RoutingGeneration,
|
||||
retainCurrent map[string]struct{},
|
||||
) (*RoutingGeneration, map[string]string, error) {
|
||||
candidates := effectivePlugins(factory, override)
|
||||
accepted := make(map[string]*LoadedPlugin, len(candidates))
|
||||
currentByKey := make(map[string]*LoadedPlugin)
|
||||
if current != nil {
|
||||
for _, plugin := range current.plugins {
|
||||
currentByKey[plugin.Meta.Key] = plugin
|
||||
}
|
||||
}
|
||||
generation, err := buildRoutingGenerationFromPlugins(accepted, number)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("initialize routing generation: %w", err)
|
||||
}
|
||||
|
||||
unchangedKeys := make([]string, 0)
|
||||
changedKeys := make([]string, 0)
|
||||
newKeys := make([]string, 0)
|
||||
for key, candidate := range candidates {
|
||||
incumbent, exists := currentByKey[key]
|
||||
switch {
|
||||
case exists && candidate == incumbent:
|
||||
unchangedKeys = append(unchangedKeys, key)
|
||||
case exists:
|
||||
changedKeys = append(changedKeys, key)
|
||||
default:
|
||||
newKeys = append(newKeys, key)
|
||||
}
|
||||
}
|
||||
sort.Strings(unchangedKeys)
|
||||
sort.Strings(changedKeys)
|
||||
sort.Strings(newKeys)
|
||||
|
||||
routingErrors := make(map[string]string)
|
||||
orderedKeys := append(unchangedKeys, changedKeys...)
|
||||
orderedKeys = append(orderedKeys, newKeys...)
|
||||
rejectedKeys := make([]string, 0)
|
||||
for _, key := range orderedKeys {
|
||||
candidate := candidates[key]
|
||||
accepted[key] = candidate
|
||||
trial, trialErr := buildRoutingGenerationFromPlugins(accepted, number)
|
||||
if trialErr == nil {
|
||||
generation = trial
|
||||
continue
|
||||
}
|
||||
delete(accepted, key)
|
||||
routingErrors[key] = fmt.Sprintf("plugin %s rejected from routing generation: %v", key, trialErr)
|
||||
rejectedKeys = append(rejectedKeys, key)
|
||||
}
|
||||
|
||||
for _, key := range rejectedKeys {
|
||||
candidate := candidates[key]
|
||||
incumbent, hasIncumbent := currentByKey[key]
|
||||
_, mayRetain := retainCurrent[key]
|
||||
if !hasIncumbent || !mayRetain || incumbent == candidate {
|
||||
continue
|
||||
}
|
||||
accepted[key] = incumbent
|
||||
fallback, fallbackErr := buildRoutingGenerationFromPlugins(accepted, number)
|
||||
if fallbackErr == nil {
|
||||
generation = fallback
|
||||
continue
|
||||
}
|
||||
delete(accepted, key)
|
||||
}
|
||||
generation.retainCurrent = cloneStringSet(retainCurrent)
|
||||
return generation, routingErrors, nil
|
||||
}
|
||||
|
||||
func effectivePlugins(factory, override map[string]*LoadedPlugin) map[string]*LoadedPlugin {
|
||||
effective := make(map[string]*LoadedPlugin, len(factory)+len(override))
|
||||
maps.Copy(effective, factory)
|
||||
maps.Copy(effective, override)
|
||||
return effective
|
||||
}
|
||||
|
||||
func buildRoutingGenerationFromPlugins(effective map[string]*LoadedPlugin, number uint64) (*RoutingGeneration, error) {
|
||||
keys := make([]string, 0, len(effective))
|
||||
for key := range effective {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
generation := &RoutingGeneration{
|
||||
Number: number,
|
||||
PublishedAt: time.Now(),
|
||||
byKey: make(map[string]*LoadedPlugin, len(effective)),
|
||||
byModel: make(map[string]*LoadedPlugin),
|
||||
canonicalModelByFold: make(map[string]string),
|
||||
byChannelType: make(map[int]*LoadedPlugin),
|
||||
routeIndex: make(map[string]RouteBinding),
|
||||
protocolIndex: make(map[string][]ProtocolBinding),
|
||||
plugins: make([]*LoadedPlugin, 0, len(effective)),
|
||||
}
|
||||
for _, key := range keys {
|
||||
plugin := effective[key]
|
||||
generation.byKey[key] = plugin
|
||||
generation.plugins = append(generation.plugins, plugin)
|
||||
for _, model := range plugin.Meta.Models {
|
||||
if _, exists := generation.byModel[model]; !exists {
|
||||
generation.byModel[model] = plugin
|
||||
}
|
||||
folded := asciiFold(model)
|
||||
if existing, exists := generation.canonicalModelByFold[folded]; exists {
|
||||
if existing != model {
|
||||
otherKey := plugin.Meta.Key
|
||||
if other, ok := generation.byModel[existing]; ok {
|
||||
otherKey = other.Meta.Key
|
||||
}
|
||||
return nil, fmt.Errorf("plugin %s model %q conflicts with plugin %s model %q", plugin.Meta.Key, model, otherKey, existing)
|
||||
}
|
||||
continue
|
||||
}
|
||||
generation.canonicalModelByFold[folded] = model
|
||||
}
|
||||
|
||||
for _, channelType := range plugin.Meta.ChannelTypes {
|
||||
if channelType == 0 || channelType == constant.ChannelTypeTaskPlugin {
|
||||
continue
|
||||
}
|
||||
if other, exists := generation.byChannelType[channelType]; exists {
|
||||
return nil, fmt.Errorf("plugin %s channelType %d conflicts with plugin %s", plugin.Meta.Key, channelType, other.Meta.Key)
|
||||
}
|
||||
generation.byChannelType[channelType] = plugin
|
||||
}
|
||||
|
||||
for _, route := range plugin.Meta.Routes {
|
||||
shape, err := routePathShape(route.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
indexKey := route.Method + " " + shape
|
||||
if other, exists := generation.routeIndex[indexKey]; exists {
|
||||
return nil, fmt.Errorf("plugin %s route %s %s conflicts with plugin %s route %s", plugin.Meta.Key, route.Method, route.Path, other.Plugin.Meta.Key, other.Route.Path)
|
||||
}
|
||||
binding := RouteBinding{Plugin: plugin, Route: route}
|
||||
generation.routeIndex[indexKey] = binding
|
||||
generation.routes = append(generation.routes, binding)
|
||||
}
|
||||
|
||||
for _, claim := range plugin.Meta.Protocols {
|
||||
definition, _ := HostProtocol(claim.Name)
|
||||
boundModels := plugin.Meta.Models
|
||||
if len(claim.Models) > 0 {
|
||||
boundModels = claim.Models
|
||||
}
|
||||
for _, operation := range definition.Operations {
|
||||
if operation.ModelField == "" {
|
||||
continue
|
||||
}
|
||||
for _, method := range operation.Methods {
|
||||
for _, model := range boundModels {
|
||||
indexKey := endpointIndexKey(method, operation.Path, model)
|
||||
bindings := generation.protocolIndex[indexKey]
|
||||
if len(bindings) > 0 {
|
||||
other := bindings[0]
|
||||
legacyProviders := len(plugin.Meta.ChannelTypes) > 0 && len(other.Plugin.Meta.ChannelTypes) > 0
|
||||
if !legacyProviders || claim.Name != other.Protocol {
|
||||
return nil, fmt.Errorf("plugin %s protocol %s %s model %q conflicts with plugin %s", plugin.Meta.Key, method, operation.Path, model, other.Plugin.Meta.Key)
|
||||
}
|
||||
}
|
||||
generation.protocolIndex[indexKey] = append(bindings, ProtocolBinding{Plugin: plugin, Protocol: claim.Name, Operation: operation, Model: model})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return generation, nil
|
||||
}
|
||||
|
||||
// PreflightRoutingConflict reports whether admitting candidate into the
|
||||
// current generation would collide on a channel type, native route, or
|
||||
// protocol-model binding. A same-key entry is replaced first so re-uploading
|
||||
// a plugin (or overriding a factory built-in) does not self-conflict.
|
||||
func PreflightRoutingConflict(current *RoutingGeneration, candidate *LoadedPlugin) error {
|
||||
if candidate == nil {
|
||||
return fmt.Errorf("cannot preflight a nil plugin")
|
||||
}
|
||||
effective := make(map[string]*LoadedPlugin)
|
||||
number := uint64(0)
|
||||
if current != nil {
|
||||
for _, plugin := range current.Plugins() {
|
||||
effective[plugin.Meta.Key] = plugin
|
||||
}
|
||||
number = current.Number
|
||||
}
|
||||
effective[candidate.Meta.Key] = candidate
|
||||
_, err := buildRoutingGenerationFromPlugins(effective, number)
|
||||
return err
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,139 @@
|
||||
package jsplugin
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/grafana/sobek"
|
||||
)
|
||||
|
||||
type volcSignRequest struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
Body string `json:"body"`
|
||||
AccessKey string `json:"accessKey"`
|
||||
SecretKey string `json:"secretKey"`
|
||||
Region string `json:"region"`
|
||||
Service string `json:"service"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
func injectGlobals(runtime *sobek.Runtime, identity func() string, now func() time.Time, logOutput func(string)) error {
|
||||
utils := map[string]any{
|
||||
"unixNow": func() int64 { return now().Unix() },
|
||||
"jwtSignHS256": func(claims map[string]any, secret string) (string, error) {
|
||||
return jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims(claims)).SignedString([]byte(secret))
|
||||
},
|
||||
"hmacSHA256": func(message, secret string) string {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(message))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
},
|
||||
"base64": func(value string) string { return base64.StdEncoding.EncodeToString([]byte(value)) },
|
||||
"base64URL": func(value string) string {
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(value))
|
||||
},
|
||||
"base64URLDecode": func(value string) (string, error) {
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(value)
|
||||
return string(decoded), err
|
||||
},
|
||||
"uuid": func() string { return uuid.NewString() },
|
||||
"volcSignV4": func(request volcSignRequest) (map[string]string, error) {
|
||||
return signVolcV4(request, now)
|
||||
},
|
||||
}
|
||||
if err := runtime.Set("utils", utils); err != nil {
|
||||
return err
|
||||
}
|
||||
console := runtime.NewObject()
|
||||
if err := console.Set("log", func(call sobek.FunctionCall) sobek.Value {
|
||||
parts := make([]string, len(call.Arguments))
|
||||
for i, argument := range call.Arguments {
|
||||
parts[i] = argument.String()
|
||||
}
|
||||
if logOutput != nil {
|
||||
logOutput(identity() + " " + strings.Join(parts, " "))
|
||||
}
|
||||
return sobek.Undefined()
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return runtime.Set("console", console)
|
||||
}
|
||||
|
||||
func signVolcV4(request volcSignRequest, now func() time.Time) (map[string]string, error) {
|
||||
parsedURL, err := url.Parse(request.URL)
|
||||
if err != nil || parsedURL.Host == "" {
|
||||
return nil, fmt.Errorf("invalid Volcengine signing URL")
|
||||
}
|
||||
region := request.Region
|
||||
if region == "" {
|
||||
region = "cn-north-1"
|
||||
}
|
||||
service := request.Service
|
||||
if service == "" {
|
||||
service = "cv"
|
||||
}
|
||||
timestamp := now().UTC()
|
||||
if request.Timestamp != 0 {
|
||||
timestamp = time.Unix(request.Timestamp, 0).UTC()
|
||||
}
|
||||
xDate := timestamp.Format("20060102T150405Z")
|
||||
shortDate := timestamp.Format("20060102")
|
||||
bodyHash := sha256.Sum256([]byte(request.Body))
|
||||
requestPath := parsedURL.EscapedPath()
|
||||
if requestPath == "" {
|
||||
requestPath = "/"
|
||||
}
|
||||
|
||||
headers := make(map[string]string, len(request.Headers)+3)
|
||||
for name, value := range request.Headers {
|
||||
headers[strings.ToLower(name)] = strings.TrimSpace(value)
|
||||
}
|
||||
headers["host"] = parsedURL.Host
|
||||
headers["x-date"] = xDate
|
||||
headers["x-content-sha256"] = hex.EncodeToString(bodyHash[:])
|
||||
keys := make([]string, 0, len(headers))
|
||||
for name := range headers {
|
||||
keys = append(keys, name)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var canonicalHeaders strings.Builder
|
||||
for _, name := range keys {
|
||||
canonicalHeaders.WriteString(name)
|
||||
canonicalHeaders.WriteByte(':')
|
||||
canonicalHeaders.WriteString(headers[name])
|
||||
canonicalHeaders.WriteByte('\n')
|
||||
}
|
||||
signedHeaders := strings.Join(keys, ";")
|
||||
canonicalRequest := strings.Join([]string{
|
||||
strings.ToUpper(request.Method), requestPath, parsedURL.Query().Encode(),
|
||||
canonicalHeaders.String(), signedHeaders, hex.EncodeToString(bodyHash[:]),
|
||||
}, "\n")
|
||||
canonicalHash := sha256.Sum256([]byte(canonicalRequest))
|
||||
scope := fmt.Sprintf("%s/%s/%s/request", shortDate, region, service)
|
||||
stringToSign := fmt.Sprintf("HMAC-SHA256\n%s\n%s\n%s", xDate, scope, hex.EncodeToString(canonicalHash[:]))
|
||||
sign := func(key []byte, value string) []byte {
|
||||
mac := hmac.New(sha256.New, key)
|
||||
_, _ = mac.Write([]byte(value))
|
||||
return mac.Sum(nil)
|
||||
}
|
||||
signingKey := sign(sign(sign([]byte(request.SecretKey), shortDate), region), service)
|
||||
signingKey = sign(signingKey, "request")
|
||||
signature := hex.EncodeToString(sign(signingKey, stringToSign))
|
||||
return map[string]string{
|
||||
"Authorization": fmt.Sprintf("HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s", request.AccessKey, scope, signedHeaders, signature),
|
||||
"X-Date": xDate,
|
||||
"X-Content-Sha256": hex.EncodeToString(bodyHash[:]),
|
||||
}, nil
|
||||
}
|
||||
Reference in new issue
Block a user