feat: publish v1.0.0-rc.36-custom.1

This commit is contained in:
Codex committed 2026-09-11 05:07:48 +08:00
commit 3a7e8f3b54
2381 files changed
+551494

No files matched your search

+92
View File
@@ -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 ""
}
+57
View File
@@ -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(); }
`
+574
View File
@@ -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()
}
+375
View File
@@ -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())
})
}
}
+116
View File
@@ -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
}
+49
View File
@@ -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)
}
+126
View File
@@ -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://")
}
+26
View File
@@ -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)
}
+245
View File
@@ -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
+43
View File
@@ -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)
}
+963
View File
@@ -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
+139
View File
@@ -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
}