diff --git a/examples/plugin/jshandler/Makefile b/examples/plugin/jshandler/Makefile new file mode 100644 index 00000000..f1db3a3c --- /dev/null +++ b/examples/plugin/jshandler/Makefile @@ -0,0 +1,23 @@ +PLUGIN_NAME ?= jshandler +BUILD_DIR ?= . +GOOS ?= $(shell go env GOOS) +GOARCH ?= $(shell go env GOARCH) + +EXT_linux = so +EXT_freebsd = so +EXT_darwin = dylib +EXT_windows = dll +PLUGIN_EXT = $(or $(EXT_$(GOOS)),so) +PLUGIN_OUTPUT ?= $(BUILD_DIR)/$(PLUGIN_NAME).$(PLUGIN_EXT) +PLUGIN_HEADER = $(basename $(PLUGIN_OUTPUT)).h + +.PHONY: build clean + +build: + CGO_ENABLED=1 GOOS=$(GOOS) GOARCH=$(GOARCH) go build -buildmode=c-shared -o $(PLUGIN_OUTPUT) . + +clean: + rm -f $(BUILD_DIR)/$(PLUGIN_NAME).so + rm -f $(BUILD_DIR)/$(PLUGIN_NAME).dylib + rm -f $(BUILD_DIR)/$(PLUGIN_NAME).dll + rm -f $(PLUGIN_HEADER) diff --git a/examples/plugin/jshandler/README.md b/examples/plugin/jshandler/README.md new file mode 100644 index 00000000..e9b5aca4 --- /dev/null +++ b/examples/plugin/jshandler/README.md @@ -0,0 +1,119 @@ +# JS Handler Plugin + +A CLIProxyAPI plugin that executes external JavaScript scripts to intercept and modify requests, responses, and streaming chunks using the Goja VM engine. + +## Features + +- **Request Interception** (`on_before_request`): Modify request payloads and headers before upstream delivery. +- **Response Interception** (`on_after_nonstream_response`): Modify non-streaming response bodies and headers. +- **Stream Chunk Interception** (`on_after_stream_response`): Modify individual streaming chunks with read-only `history_chunks` context. +- **Hot Reload**: Scripts are automatically reloaded when modified on disk. +- **Execution Timeout**: Configurable timeout prevents infinite loops. +- **Graceful Degradation**: Original data is preserved on JS execution errors. + +## Configuration + +```yaml +plugins: + enabled: true + dir: "plugins-dir" + configs: + jshandler: + enabled: true + script_paths: + - /path/to/custom_handler.js + - ./relative_handler.js + timeout: 1s +``` + +### Fields + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `enabled` | boolean | `true` | Enable or disable the plugin | +| `script_paths` | array | `[]` | JS script file paths (absolute or relative to plugin directory) | +| `timeout` | string | `1s` | Execution timeout per JS hook call | + +## JS Script API + +Scripts can export these global functions: + +### `on_before_request(ctx)` + +Called before the request is sent upstream. + +**ctx structure:** +```javascript +{ + "id": "request-id", + "body": "...", // Request body string + "headers": {}, // Request headers + "url": "", + "model": "gpt-4", + "protocol": "openai" +} +``` + +### `on_after_nonstream_response(ctx)` + +Called after a non-streaming response is received from upstream. + +**ctx structure (non-streaming):** +```javascript +{ + "id": "request-id", + "body": "...", // Full response body + "req": { "body": "...", "headers": {}, "url": "" }, + "protocol": "openai", + "headers": {}, + "chunk": null, + "history_chunks": null +} +``` + +### `on_after_stream_response(ctx)` + +Called after each streaming response chunk is received from upstream. + +**ctx structure:** +```javascript +{ + "id": "request-id", + "body": null, + "req": { "body": "...", "headers": {}, "url": "" }, + "protocol": "openai", + "headers": {}, + "chunk": "...", // Current writable chunk + "history_chunks": ["..."] // Read-only frozen array +} +``` + +### Return Value + +Return the modified `ctx` object, or a plain string to replace the body/chunk. + +## Built-in Scripts + +The `scripts/` directory contains built-in scripts loaded automatically: + +- `copilot_handler.js`: Fixes tool-call `finish_reason` for GitHub Copilot compatibility. + +## Building + +```bash +make build +``` + +The Makefile chooses the plugin extension from the target platform: + +| GOOS | Output | +|------|--------| +| `linux` / `freebsd` | `jshandler.so` | +| `darwin` | `jshandler.dylib` | +| `windows` | `jshandler.dll` | + +You can override the target and output directory: + +```bash +make build GOOS=darwin GOARCH=arm64 BUILD_DIR=/path/to/plugins/darwin/arm64 +``` diff --git a/examples/plugin/jshandler/abi.go b/examples/plugin/jshandler/abi.go new file mode 100644 index 00000000..d4e3b39e --- /dev/null +++ b/examples/plugin/jshandler/abi.go @@ -0,0 +1,291 @@ +package main + +/* +#define _GNU_SOURCE +#include +#include +#include + +typedef struct { + void* ptr; + size_t len; +} cliproxy_buffer; + +typedef int (*cliproxy_host_call_fn)(void*, const char*, const uint8_t*, size_t, cliproxy_buffer*); +typedef void (*cliproxy_host_free_fn)(void*, size_t); + +typedef struct { + uint32_t abi_version; + void* host_ctx; + cliproxy_host_call_fn call; + cliproxy_host_free_fn free_buffer; +} cliproxy_host_api; + +typedef int (*cliproxy_plugin_call_fn)(char*, uint8_t*, size_t, cliproxy_buffer*); +typedef void (*cliproxy_plugin_free_fn)(void*, size_t); +typedef void (*cliproxy_plugin_shutdown_fn)(void); + +typedef struct { + uint32_t abi_version; + cliproxy_plugin_call_fn call; + cliproxy_plugin_free_fn free_buffer; + cliproxy_plugin_shutdown_fn shutdown; +} cliproxy_plugin_api; + +extern int JSHandlerPluginCall(char*, uint8_t*, size_t, cliproxy_buffer*); +extern void JSHandlerPluginFree(void*, size_t); +extern void JSHandlerPluginShutdown(void); + +static const char* jshandler_shared_object_path() { + Dl_info info; + if (dladdr((void*)&JSHandlerPluginCall, &info) == 0 || info.dli_fname == NULL) { + return NULL; + } + return info.dli_fname; +} + +static int jshandler_call_host(cliproxy_host_api* api, const char* method, const uint8_t* request, size_t request_len, cliproxy_buffer* response) { + return api->call(api->host_ctx, method, request, request_len, response); +} + +static void jshandler_free_host_buffer(cliproxy_host_api* api, void* ptr, size_t len) { + api->free_buffer(ptr, len); +} +*/ +import "C" + +import ( + "context" + "encoding/json" + "fmt" + "path/filepath" + "sync" + "unsafe" + + "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" +) + +var jsHandlerABIState = struct { + sync.RWMutex + host *C.cliproxy_host_api + plugin *jsHandlerPlugin + shuttingDown bool + inFlight sync.WaitGroup +}{} + +const maxCGoBytesLen = C.size_t(1<<31 - 1) + +type abiEnvelope struct { + OK bool `json:"ok"` + Result json.RawMessage `json:"result,omitempty"` + Error *abiError `json:"error,omitempty"` +} + +type abiError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +type abiLifecycleRequest struct { + ConfigYAML []byte `json:"config_yaml"` + PluginDir string `json:"plugin_dir,omitempty"` +} + +type abiRegistration struct { + SchemaVersion uint32 `json:"schema_version"` + Metadata pluginapi.Metadata `json:"metadata"` + Capabilities abiCapabilities `json:"capabilities"` +} + +type abiCapabilities struct { + RequestInterceptor bool `json:"request_interceptor"` + ResponseInterceptor bool `json:"response_interceptor"` + StreamChunkInterceptor bool `json:"response_stream_interceptor"` +} + +type abiIdentifierResponse struct { + Identifier string `json:"identifier"` +} + +func main() {} + +func inferPluginDir() string { + sharedObjectPath := C.jshandler_shared_object_path() + if sharedObjectPath == nil { + return "" + } + return filepath.Dir(C.GoString(sharedObjectPath)) +} + +//export cliproxy_plugin_init +func cliproxy_plugin_init(host *C.cliproxy_host_api, plugin *C.cliproxy_plugin_api) C.int { + if host == nil || plugin == nil { + return 1 + } + jsHandlerABIState.Lock() + jsHandlerABIState.host = host + jsHandlerABIState.shuttingDown = false + jsHandlerABIState.Unlock() + + plugin.abi_version = C.uint32_t(pluginabi.ABIVersion) + plugin.call = C.cliproxy_plugin_call_fn(C.JSHandlerPluginCall) + plugin.free_buffer = C.cliproxy_plugin_free_fn(C.JSHandlerPluginFree) + plugin.shutdown = C.cliproxy_plugin_shutdown_fn(C.JSHandlerPluginShutdown) + return 0 +} + +//export JSHandlerPluginCall +func JSHandlerPluginCall(method *C.char, request *C.uint8_t, requestLen C.size_t, response *C.cliproxy_buffer) C.int { + if response != nil { + response.ptr = nil + response.len = 0 + } + if method == nil { + writeABIResponse(response, abiErrorEnvelope("invalid_method", "method is required")) + return 0 + } + var requestBytes []byte + if request != nil && requestLen > 0 { + if requestLen > maxCGoBytesLen { + writeABIResponse(response, abiErrorEnvelope("request_too_large", "request payload is too large")) + return 0 + } + requestBytes = C.GoBytes(unsafe.Pointer(request), C.int(requestLen)) + } + raw, errHandle := handleJSHandlerABIMethod(context.Background(), C.GoString(method), requestBytes) + if errHandle != nil { + writeABIResponse(response, abiErrorEnvelope("plugin_error", errHandle.Error())) + return 0 + } + writeABIResponse(response, raw) + return 0 +} + +//export JSHandlerPluginFree +func JSHandlerPluginFree(ptr unsafe.Pointer, len C.size_t) { + if ptr != nil { + C.free(ptr) + } +} + +//export JSHandlerPluginShutdown +func JSHandlerPluginShutdown() { + jsHandlerABIState.Lock() + jsHandlerABIState.shuttingDown = true + jsHandlerABIState.plugin = nil + jsHandlerABIState.host = nil + jsHandlerABIState.Unlock() + jsHandlerABIState.inFlight.Wait() +} + +func handleJSHandlerABIMethod(ctx context.Context, method string, request []byte) ([]byte, error) { + switch method { + case pluginabi.MethodPluginRegister, pluginabi.MethodPluginReconfigure: + return handleJSHandlerRegister(request) + } + + p, done, errPlugin := beginJSHandlerPluginCall() + if errPlugin != nil { + return nil, errPlugin + } + defer done() + switch method { + case pluginabi.MethodRequestInterceptBefore: + var req pluginapi.RequestInterceptRequest + if errDecode := json.Unmarshal(request, &req); errDecode != nil { + return nil, errDecode + } + resp, errCall := p.InterceptRequest(ctx, req) + return abiOKEnvelopeWithError(resp, errCall) + case pluginabi.MethodResponseInterceptAfter: + var req pluginapi.ResponseInterceptRequest + if errDecode := json.Unmarshal(request, &req); errDecode != nil { + return nil, errDecode + } + resp, errCall := p.InterceptResponse(ctx, req) + return abiOKEnvelopeWithError(resp, errCall) + case pluginabi.MethodResponseInterceptStreamChunk: + var req pluginapi.StreamChunkInterceptRequest + if errDecode := json.Unmarshal(request, &req); errDecode != nil { + return nil, errDecode + } + resp, errCall := p.InterceptStreamChunk(ctx, req) + return abiOKEnvelopeWithError(resp, errCall) + default: + return abiErrorEnvelope("unknown_method", "unknown method: "+method), nil + } +} + +func handleJSHandlerRegister(request []byte) ([]byte, error) { + var req abiLifecycleRequest + if errDecode := json.Unmarshal(request, &req); errDecode != nil { + return nil, errDecode + } + plugin, errBuild := buildPlugin(req.ConfigYAML, req.PluginDir) + if errBuild != nil { + return nil, errBuild + } + p, ok := plugin.Capabilities.RequestInterceptor.(*jsHandlerPlugin) + if !ok || p == nil { + return nil, fmt.Errorf("jshandler plugin registration returned invalid interceptor") + } + jsHandlerABIState.Lock() + jsHandlerABIState.plugin = p + jsHandlerABIState.shuttingDown = false + jsHandlerABIState.Unlock() + return abiOKEnvelope(abiRegistration{ + SchemaVersion: pluginabi.SchemaVersion, + Metadata: plugin.Metadata, + Capabilities: abiCapabilities{ + RequestInterceptor: plugin.Capabilities.RequestInterceptor != nil, + ResponseInterceptor: plugin.Capabilities.ResponseInterceptor != nil, + StreamChunkInterceptor: plugin.Capabilities.StreamChunkInterceptor != nil, + }, + }) +} + +func beginJSHandlerPluginCall() (*jsHandlerPlugin, func(), error) { + jsHandlerABIState.Lock() + defer jsHandlerABIState.Unlock() + if jsHandlerABIState.shuttingDown { + return nil, nil, fmt.Errorf("jshandler plugin is shutting down") + } + if jsHandlerABIState.plugin == nil { + return nil, nil, fmt.Errorf("jshandler plugin is not registered") + } + jsHandlerABIState.inFlight.Add(1) + return jsHandlerABIState.plugin, jsHandlerABIState.inFlight.Done, nil +} + +func abiOKEnvelopeWithError(v any, err error) ([]byte, error) { + if err != nil { + return nil, err + } + return abiOKEnvelope(v) +} + +func abiOKEnvelope(v any) ([]byte, error) { + raw, errMarshal := json.Marshal(v) + if errMarshal != nil { + return nil, errMarshal + } + return json.Marshal(abiEnvelope{OK: true, Result: raw}) +} + +func abiErrorEnvelope(code, message string) []byte { + raw, _ := json.Marshal(abiEnvelope{OK: false, Error: &abiError{Code: code, Message: message}}) + return raw +} + +func writeABIResponse(response *C.cliproxy_buffer, raw []byte) { + if response == nil || len(raw) == 0 { + return + } + ptr := C.CBytes(raw) + if ptr == nil { + return + } + response.ptr = ptr + response.len = C.size_t(len(raw)) +} diff --git a/examples/plugin/jshandler/abi_test.go b/examples/plugin/jshandler/abi_test.go new file mode 100644 index 00000000..c46eb1f5 --- /dev/null +++ b/examples/plugin/jshandler/abi_test.go @@ -0,0 +1,36 @@ +package main + +import ( + "encoding/json" + "testing" +) + +func TestABIRegistrationUsesHostStreamCapabilityField(t *testing.T) { + raw, errMarshal := abiOKEnvelope(abiRegistration{ + Capabilities: abiCapabilities{ + RequestInterceptor: true, + ResponseInterceptor: true, + StreamChunkInterceptor: true, + }, + }) + if errMarshal != nil { + t.Fatalf("abiOKEnvelope() error = %v", errMarshal) + } + + var envelope abiEnvelope + if errUnmarshal := json.Unmarshal(raw, &envelope); errUnmarshal != nil { + t.Fatalf("json.Unmarshal(envelope) error = %v", errUnmarshal) + } + var result struct { + Capabilities map[string]bool `json:"capabilities"` + } + if errUnmarshal := json.Unmarshal(envelope.Result, &result); errUnmarshal != nil { + t.Fatalf("json.Unmarshal(result) error = %v", errUnmarshal) + } + if !result.Capabilities["response_stream_interceptor"] { + t.Fatalf("response_stream_interceptor capability was not advertised: %v", result.Capabilities) + } + if _, exists := result.Capabilities["stream_chunk_interceptor"]; exists { + t.Fatalf("legacy stream_chunk_interceptor field should not be advertised: %v", result.Capabilities) + } +} diff --git a/examples/plugin/jshandler/config.go b/examples/plugin/jshandler/config.go new file mode 100644 index 00000000..9a6c24f2 --- /dev/null +++ b/examples/plugin/jshandler/config.go @@ -0,0 +1,140 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +const jsHandlerProvider = "jshandler" +const pluginName = "jshandler" + +type jsHandlerConfig struct { + Enabled bool `yaml:"enabled"` + ScriptPaths []string `yaml:"script_paths"` + TimeoutRaw string `yaml:"timeout"` + Timeout time.Duration `yaml:"-"` +} + +func defaultJSHandlerConfig() jsHandlerConfig { + return jsHandlerConfig{ + Enabled: true, + Timeout: 1 * time.Second, + } +} + +func parseJSHandlerConfig(raw []byte) (jsHandlerConfig, error) { + cfg := defaultJSHandlerConfig() + if len(strings.TrimSpace(string(raw))) > 0 { + if errUnmarshal := yaml.Unmarshal(raw, &cfg); errUnmarshal != nil { + return cfg, fmt.Errorf("invalid jshandler config: %w", errUnmarshal) + } + } + if strings.TrimSpace(cfg.TimeoutRaw) != "" { + parsed, errParse := time.ParseDuration(strings.TrimSpace(cfg.TimeoutRaw)) + if errParse != nil || parsed <= 0 { + return cfg, fmt.Errorf("invalid jshandler timeout %q", cfg.TimeoutRaw) + } + cfg.Timeout = parsed + } + if cfg.Timeout <= 0 { + cfg.Timeout = 1 * time.Second + } + return cfg, nil +} + +func (cfg *jsHandlerConfig) resolvedScriptPaths(pluginDir string) ([]string, error) { + var paths []string + for _, p := range cfg.ScriptPaths { + p = strings.TrimSpace(p) + if p == "" { + continue + } + originalPath := p + relativePath := !filepath.IsAbs(p) + if !filepath.IsAbs(p) { + if pluginDir == "" { + return nil, fmt.Errorf("relative script path %q requires plugin_dir", originalPath) + } + p = filepath.Join(pluginDir, p) + if !isPathWithinDir(p, pluginDir) { + return nil, fmt.Errorf("relative script path %q escapes plugin_dir", originalPath) + } + } + cleanPath, errClean := filepath.Abs(filepath.Clean(p)) + if errClean != nil { + return nil, errClean + } + if relativePath { + resolvedPath, errEval := filepath.EvalSymlinks(cleanPath) + if errEval != nil { + return nil, errEval + } + if !isResolvedPathWithinDir(resolvedPath, pluginDir) { + return nil, fmt.Errorf("relative script path %q escapes plugin_dir through symlink", originalPath) + } + cleanPath = resolvedPath + } + paths = append(paths, cleanPath) + } + return paths, nil +} + +func builtinScriptPaths(pluginDir string) []string { + if pluginDir == "" { + return nil + } + scriptsDir := filepath.Join(pluginDir, "scripts") + cleanScriptsDir, errClean := filepath.Abs(filepath.Clean(scriptsDir)) + if errClean != nil { + return nil + } + entries, errRead := os.ReadDir(scriptsDir) + if errRead != nil { + return nil + } + var paths []string + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if strings.HasSuffix(strings.ToLower(name), ".js") { + candidate := filepath.Join(cleanScriptsDir, name) + resolved, errEval := filepath.EvalSymlinks(candidate) + if errEval != nil || !isResolvedPathWithinDir(resolved, cleanScriptsDir) { + continue + } + paths = append(paths, resolved) + } + } + return paths +} + +func isPathWithinDir(path, dir string) bool { + cleanPath, errPath := filepath.Abs(filepath.Clean(path)) + if errPath != nil { + return false + } + cleanDir, errDir := filepath.Abs(filepath.Clean(dir)) + if errDir != nil { + return false + } + rel, errRel := filepath.Rel(cleanDir, cleanPath) + if errRel != nil { + return false + } + return rel == "." || (rel != "" && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != "..") +} + +func isResolvedPathWithinDir(path, dir string) bool { + resolvedDir, errEval := filepath.EvalSymlinks(dir) + if errEval != nil { + return false + } + return isPathWithinDir(path, resolvedDir) +} diff --git a/examples/plugin/jshandler/config_test.go b/examples/plugin/jshandler/config_test.go new file mode 100644 index 00000000..8ff3abb1 --- /dev/null +++ b/examples/plugin/jshandler/config_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestResolvedScriptPathsRejectsRelativeSymlinkEscapingPluginDir(t *testing.T) { + pluginDir := t.TempDir() + outsideDir := t.TempDir() + outsideScript := filepath.Join(outsideDir, "handler.js") + if errWrite := os.WriteFile(outsideScript, []byte("function on_before_request(ctx) { return ctx; }\n"), 0600); errWrite != nil { + t.Fatalf("os.WriteFile() error = %v", errWrite) + } + + linkPath := filepath.Join(pluginDir, "handler.js") + if errSymlink := os.Symlink(outsideScript, linkPath); errSymlink != nil { + t.Skipf("os.Symlink() is not available: %v", errSymlink) + } + + cfg := jsHandlerConfig{ScriptPaths: []string{"handler.js"}} + _, errResolve := cfg.resolvedScriptPaths(pluginDir) + if errResolve == nil { + t.Fatal("resolvedScriptPaths() expected error for escaping symlink") + } + if !strings.Contains(errResolve.Error(), "escapes plugin_dir") { + t.Fatalf("resolvedScriptPaths() error = %v, want escapes plugin_dir", errResolve) + } +} + +func TestResolvedScriptPathsAllowsRelativeSymlinkInsidePluginDir(t *testing.T) { + pluginDir := t.TempDir() + scriptsDir := filepath.Join(pluginDir, "scripts") + if errMkdir := os.Mkdir(scriptsDir, 0700); errMkdir != nil { + t.Fatalf("os.Mkdir() error = %v", errMkdir) + } + realScript := filepath.Join(scriptsDir, "handler.js") + if errWrite := os.WriteFile(realScript, []byte("function on_before_request(ctx) { return ctx; }\n"), 0600); errWrite != nil { + t.Fatalf("os.WriteFile() error = %v", errWrite) + } + + linkPath := filepath.Join(pluginDir, "handler.js") + if errSymlink := os.Symlink(realScript, linkPath); errSymlink != nil { + t.Skipf("os.Symlink() is not available: %v", errSymlink) + } + + cfg := jsHandlerConfig{ScriptPaths: []string{"handler.js"}} + paths, errResolve := cfg.resolvedScriptPaths(pluginDir) + if errResolve != nil { + t.Fatalf("resolvedScriptPaths() error = %v", errResolve) + } + if len(paths) != 1 { + t.Fatalf("resolvedScriptPaths() returned %d paths, want 1", len(paths)) + } + resolvedRealScript, errEval := filepath.EvalSymlinks(realScript) + if errEval != nil { + t.Fatalf("filepath.EvalSymlinks() error = %v", errEval) + } + if paths[0] != resolvedRealScript { + t.Fatalf("resolvedScriptPaths()[0] = %q, want %q", paths[0], resolvedRealScript) + } +} diff --git a/examples/plugin/jshandler/engine.go b/examples/plugin/jshandler/engine.go new file mode 100644 index 00000000..8da181ff --- /dev/null +++ b/examples/plugin/jshandler/engine.go @@ -0,0 +1,183 @@ +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "github.com/dop251/goja" + log "github.com/sirupsen/logrus" +) + +type jsEngine struct { + vm *goja.Runtime +} + +const maxJSScriptBytes = 8 * 1024 * 1024 + +func newJSEngine() *jsEngine { + engine := &jsEngine{ + vm: goja.New(), + } + engine.initConsole() + return engine +} + +func (engine *jsEngine) initConsole() { + console := engine.vm.NewObject() + consoleLogWrapper := func(call goja.FunctionCall) goja.Value { + args := make([]interface{}, len(call.Arguments)) + for i, arg := range call.Arguments { + args[i] = arg.Export() + } + log.Info("JS console log: ", fmt.Sprint(args...)) + return goja.Undefined() + } + _ = console.Set("log", consoleLogWrapper) + _ = engine.vm.Set("console", console) +} + +func (engine *jsEngine) runProgram(program *goja.Program, timeout time.Duration) error { + if program == nil { + return errors.New("program is nil") + } + timer, done := engine.startInterruptTimer(timeout) + defer engine.stopInterruptTimer(timer, done) + + _, err := engine.vm.RunProgram(program) + if err != nil { + return fmt.Errorf("failed to run JS program: %w", err) + } + return nil +} + +var ErrFunctionNotFound = errors.New("function not found") +var errJSTimeout = errors.New("javascript execution timeout") + +func (engine *jsEngine) startInterruptTimer(timeout time.Duration) (*time.Timer, <-chan struct{}) { + done := make(chan struct{}) + timer := time.AfterFunc(timeout, func() { + defer close(done) + engine.vm.Interrupt(errJSTimeout) + }) + return timer, done +} + +func (engine *jsEngine) stopInterruptTimer(timer *time.Timer, done <-chan struct{}) { + if timer == nil { + return + } + if timer.Stop() { + return + } + <-done + engine.vm.ClearInterrupt() +} + +func (engine *jsEngine) frozenStringArray(values []string) (goja.Value, error) { + items := make([]interface{}, len(values)) + for i, value := range values { + items[i] = value + } + array := engine.vm.NewArray(items...) + objectValue := engine.vm.Get("Object") + if objectValue == nil || goja.IsUndefined(objectValue) { + return nil, errors.New("Object constructor is unavailable") + } + freezeValue := objectValue.ToObject(engine.vm).Get("freeze") + freezeFunc, ok := goja.AssertFunction(freezeValue) + if !ok { + return nil, errors.New("Object.freeze is unavailable") + } + if _, errFreeze := freezeFunc(goja.Undefined(), array); errFreeze != nil { + return nil, errFreeze + } + return array, nil +} + +func (engine *jsEngine) callFunction(name string, timeout time.Duration, args ...interface{}) (goja.Value, error) { + jsVal := engine.vm.Get(name) + if jsVal == nil || goja.IsUndefined(jsVal) { + return nil, fmt.Errorf("%w: function '%s' does not exist", ErrFunctionNotFound, name) + } + jsFunc, ok := goja.AssertFunction(jsVal) + if !ok { + return nil, fmt.Errorf("function '%s' is invalid", name) + } + + jsArgs := make([]goja.Value, len(args)) + for i, arg := range args { + jsArgs[i] = engine.vm.ToValue(arg) + } + + timer, done := engine.startInterruptTimer(timeout) + defer engine.stopInterruptTimer(timer, done) + + result, err := jsFunc(goja.Undefined(), jsArgs...) + if err != nil { + return nil, err + } + + return result, nil +} + +type jsCachedProgram struct { + program *goja.Program + modTime time.Time +} + +var ( + jsProgramsMU sync.RWMutex + jsProgramsCache = make(map[string]jsCachedProgram) +) + +func getJSProgram(path string) (*goja.Program, error) { + cleanPath, errClean := filepath.Abs(filepath.Clean(path)) + if errClean != nil { + return nil, errClean + } + resolvedPath, errEval := filepath.EvalSymlinks(cleanPath) + if errEval != nil { + return nil, errEval + } + info, err := os.Stat(resolvedPath) + if err != nil { + return nil, err + } + if info.Size() > maxJSScriptBytes { + return nil, fmt.Errorf("JS script %s is too large: %d bytes", resolvedPath, info.Size()) + } + modTime := info.ModTime() + + jsProgramsMU.RLock() + cached, exists := jsProgramsCache[resolvedPath] + jsProgramsMU.RUnlock() + if exists && cached.modTime.Equal(modTime) { + return cached.program, nil + } + + data, errRead := os.ReadFile(resolvedPath) + if errRead != nil { + return nil, errRead + } + + compiled, errCompile := goja.Compile(resolvedPath, string(data), false) + if errCompile != nil { + return nil, fmt.Errorf("failed to compile JS script %s: %w", resolvedPath, errCompile) + } + + jsProgramsMU.Lock() + defer jsProgramsMU.Unlock() + if cached, exists = jsProgramsCache[resolvedPath]; exists && cached.modTime.Equal(modTime) { + return cached.program, nil + } + + jsProgramsCache[resolvedPath] = jsCachedProgram{ + program: compiled, + modTime: modTime, + } + return compiled, nil +} diff --git a/examples/plugin/jshandler/engine_test.go b/examples/plugin/jshandler/engine_test.go new file mode 100644 index 00000000..33bbd8c3 --- /dev/null +++ b/examples/plugin/jshandler/engine_test.go @@ -0,0 +1,25 @@ +package main + +import ( + "testing" + "time" +) + +func TestStopInterruptTimerClearsExpiredInterrupt(t *testing.T) { + engine := newJSEngine() + timer, done := engine.startInterruptTimer(time.Nanosecond) + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("interrupt timer did not fire") + } + + engine.stopInterruptTimer(timer, done) + value, errRun := engine.vm.RunString("1 + 1") + if errRun != nil { + t.Fatalf("RunString() error after clearing interrupt = %v", errRun) + } + if got := value.ToInteger(); got != 2 { + t.Fatalf("RunString() = %d, want 2", got) + } +} diff --git a/examples/plugin/jshandler/go.mod b/examples/plugin/jshandler/go.mod new file mode 100644 index 00000000..1cd5f78d --- /dev/null +++ b/examples/plugin/jshandler/go.mod @@ -0,0 +1,18 @@ +module github.com/router-for-me/CLIProxyAPIPlugins/jshandler + +go 1.26.0 + +require ( + github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d + github.com/router-for-me/CLIProxyAPI/v7 v7.1.55 + github.com/sirupsen/logrus v1.9.4 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/dlclark/regexp2/v2 v2.2.1 // indirect + github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect + github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect +) diff --git a/examples/plugin/jshandler/go.sum b/examples/plugin/jshandler/go.sum new file mode 100644 index 00000000..7654f0cc --- /dev/null +++ b/examples/plugin/jshandler/go.sum @@ -0,0 +1,30 @@ +github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= +github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0= +github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= +github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d h1:xbM5U2EvWKkHxzEQJ2DEn20FwolWZahuTnVHr6WL3Q4= +github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d/go.mod h1:Sc+QOu1WruvaaeT/cxFez/pXHpI9ZDjg/E8QNfSVveI= +github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= +github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U= +github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/router-for-me/CLIProxyAPI/v7 v7.1.55 h1:gaZc8W025JV/CpTBpFH16af8yenC/IYuK/nBa+Age4k= +github.com/router-for-me/CLIProxyAPI/v7 v7.1.55/go.mod h1:5LQLwZuB03QHP2jsRo4Kl7pJBgsu9w0O/v1F6Ze+d4U= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/examples/plugin/jshandler/interceptor.go b/examples/plugin/jshandler/interceptor.go new file mode 100644 index 00000000..347a59ee --- /dev/null +++ b/examples/plugin/jshandler/interceptor.go @@ -0,0 +1,477 @@ +package main + +import ( + "context" + "errors" + "fmt" + "net/http" + "reflect" + "strings" + "time" + + "github.com/dop251/goja" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" + log "github.com/sirupsen/logrus" +) + +type jsHandlerPlugin struct { + cfg jsHandlerConfig + configYAML []byte + pluginDir string +} + +type processedHeaders struct { + headers http.Header + clearHeaders []string +} + +var _ pluginapi.RequestInterceptor = (*jsHandlerPlugin)(nil) +var _ pluginapi.ResponseInterceptor = (*jsHandlerPlugin)(nil) +var _ pluginapi.StreamChunkInterceptor = (*jsHandlerPlugin)(nil) + +func (p *jsHandlerPlugin) Identifier() string { + return jsHandlerProvider +} + +func (p *jsHandlerPlugin) allScriptPaths() []string { + paths := builtinScriptPaths(p.pluginDir) + configuredPaths, errPaths := p.cfg.resolvedScriptPaths(p.pluginDir) + if errPaths != nil { + log.Warnf("failed to resolve JS handler script paths: %v", errPaths) + return paths + } + paths = append(paths, configuredPaths...) + return paths +} + +func (p *jsHandlerPlugin) InterceptRequest(ctx context.Context, req pluginapi.RequestInterceptRequest) (pluginapi.RequestInterceptResponse, error) { + resp := pluginapi.RequestInterceptResponse{} + scriptPaths := p.allScriptPaths() + if len(scriptPaths) == 0 { + return resp, nil + } + + body := string(req.Body) + headers := cloneHeader(req.Headers) + var clearHeaders []string + + for _, scriptPath := range scriptPaths { + scriptPath = strings.TrimSpace(scriptPath) + if scriptPath == "" { + continue + } + processed, cleared, errJS := p.applyJSBeforeRequest(scriptPath, []byte(body), req.Model, req.SourceFormat, headers) + if errJS != nil { + log.Warnf("failed to execute JS request interceptor [%s]: %v", scriptPath, errJS) + continue + } + body = string(processed) + clearHeaders = append(clearHeaders, cleared...) + } + + if len(body) > 0 { + resp.Body = []byte(body) + } + resp.Headers = headers + resp.ClearHeaders = dedupeStrings(clearHeaders) + return resp, nil +} + +func (p *jsHandlerPlugin) InterceptResponse(ctx context.Context, req pluginapi.ResponseInterceptRequest) (pluginapi.ResponseInterceptResponse, error) { + resp := pluginapi.ResponseInterceptResponse{} + scriptPaths := p.allScriptPaths() + if len(scriptPaths) == 0 { + return resp, nil + } + + bodyStr := string(req.Body) + reqHeadersMap := headerToAnyMap(req.RequestHeaders) + respHeaders := cloneHeader(req.ResponseHeaders) + var clearHeaders []string + + for _, scriptPath := range scriptPaths { + scriptPath = strings.TrimSpace(scriptPath) + if scriptPath == "" { + continue + } + processedBody, processedHeaders, bodyModified, errJS := p.applyJSAfterResponse( + scriptPath, req.Model, req.SourceFormat, + reqHeadersMap, req.RequestBody, + bodyStr, nil, respHeaders, false, nil, + ) + if errJS != nil { + log.Warnf("failed to execute JS response interceptor [%s]: %v", scriptPath, errJS) + continue + } + if bodyModified { + bodyStr = processedBody + } + if processedHeaders != nil { + respHeaders = processedHeaders.headers + clearHeaders = append(clearHeaders, processedHeaders.clearHeaders...) + } + } + + if len(bodyStr) > 0 { + resp.Body = []byte(bodyStr) + } + resp.Headers = respHeaders + resp.ClearHeaders = dedupeStrings(clearHeaders) + return resp, nil +} + +func (p *jsHandlerPlugin) InterceptStreamChunk(ctx context.Context, req pluginapi.StreamChunkInterceptRequest) (pluginapi.StreamChunkInterceptResponse, error) { + resp := pluginapi.StreamChunkInterceptResponse{} + scriptPaths := p.allScriptPaths() + if len(scriptPaths) == 0 { + return resp, nil + } + + reqHeadersMap := headerToAnyMap(req.RequestHeaders) + respHeaders := cloneHeader(req.ResponseHeaders) + var clearHeaders []string + historyStrings := make([]string, 0, len(req.HistoryChunks)) + for _, hc := range req.HistoryChunks { + historyStrings = append(historyStrings, string(hc)) + } + + isHeaderInit := req.ChunkIndex == pluginapi.StreamChunkHeaderInitIndex + chunkStr := "" + if !isHeaderInit && len(req.Body) > 0 { + chunkStr = string(req.Body) + } + + var chunkPtr *string + chunkModified := false + if !isHeaderInit { + chunkPtr = &chunkStr + } + + for _, scriptPath := range scriptPaths { + scriptPath = strings.TrimSpace(scriptPath) + if scriptPath == "" { + continue + } + processedBody, processedHeaders, chunkChanged, errJS := p.applyJSAfterResponse( + scriptPath, req.Model, req.SourceFormat, + reqHeadersMap, req.RequestBody, + "", chunkPtr, respHeaders, !isHeaderInit, historyStrings, + ) + if errJS != nil { + log.Warnf("failed to execute JS stream chunk interceptor [%s]: %v", scriptPath, errJS) + continue + } + if processedHeaders != nil { + respHeaders = processedHeaders.headers + clearHeaders = append(clearHeaders, processedHeaders.clearHeaders...) + } + if chunkPtr != nil && chunkChanged { + *chunkPtr = processedBody + chunkModified = true + } + } + + resp.Headers = respHeaders + resp.ClearHeaders = dedupeStrings(clearHeaders) + if chunkPtr != nil && *chunkPtr != "" { + resp.Body = []byte(*chunkPtr) + } else if isHeaderInit { + // header-only init, no body to return + } else if chunkModified || len(req.Body) == 0 { + resp.DropChunk = true + } + return resp, nil +} + +func (p *jsHandlerPlugin) applyJSBeforeRequest(scriptPath string, payloadBytes []byte, model, protocol string, headers http.Header) ([]byte, []string, error) { + program, err := getJSProgram(scriptPath) + if err != nil { + return nil, nil, err + } + + engine := newJSEngine() + if errRun := engine.runProgram(program, p.cfg.Timeout); errRun != nil { + return nil, nil, errRun + } + + headersMap := headerToAnyMap(headers) + + jsCtx := map[string]any{ + "id": generateRequestID(), + "body": string(payloadBytes), + "headers": headersMap, + "url": "", + "model": model, + "protocol": protocol, + } + + jsVal, errCall := engine.callFunction("on_before_request", p.cfg.Timeout, jsCtx) + if errCall != nil { + if errors.Is(errCall, ErrFunctionNotFound) { + return payloadBytes, nil, nil + } + return nil, nil, fmt.Errorf("on_before_request failed for %s: %w", scriptPath, errCall) + } + + if jsVal == nil || goja.IsUndefined(jsVal) || goja.IsNull(jsVal) { + return payloadBytes, nil, nil + } + + exported := jsVal.Export() + if exported == nil { + return payloadBytes, nil, nil + } + + var clearHeaders []string + if objMap, ok := exported.(map[string]any); ok { + if headersVal, exists := objMap["headers"]; exists { + clearHeaders = append(clearHeaders, updateHeaderFromAny(headers, headersVal)...) + } + if bodyVal, exists := objMap["body"]; exists { + if bodyStr, okStr := bodyVal.(string); okStr { + return []byte(bodyStr), clearHeaders, nil + } + } + } + + if bodyStr, ok := exported.(string); ok { + return []byte(bodyStr), clearHeaders, nil + } + + return payloadBytes, clearHeaders, nil +} + +func (p *jsHandlerPlugin) applyJSAfterResponse( + scriptPath, model, protocol string, + reqHeadersMap map[string]any, reqBody []byte, + bodyStr string, chunkStr *string, + respHeaders http.Header, isStream bool, historyChunks []string, +) (string, *processedHeaders, bool, error) { + program, err := getJSProgram(scriptPath) + if err != nil { + return bodyStr, nil, false, err + } + + engine := newJSEngine() + if errRun := engine.runProgram(program, p.cfg.Timeout); errRun != nil { + return bodyStr, nil, false, errRun + } + + var bodyVal any = bodyStr + if isStream { + bodyVal = nil + } + + reqCtx := engine.vm.NewObject() + if errSet := reqCtx.Set("body", string(reqBody)); errSet != nil { + return bodyStr, nil, false, errSet + } + if errSet := reqCtx.Set("headers", reqHeadersMap); errSet != nil { + return bodyStr, nil, false, errSet + } + if errSet := reqCtx.Set("url", ""); errSet != nil { + return bodyStr, nil, false, errSet + } + + jsCtx := engine.vm.NewObject() + if errSet := jsCtx.Set("id", generateRequestID()); errSet != nil { + return bodyStr, nil, false, errSet + } + if errSet := jsCtx.Set("body", bodyVal); errSet != nil { + return bodyStr, nil, false, errSet + } + if errSet := jsCtx.Set("req", reqCtx); errSet != nil { + return bodyStr, nil, false, errSet + } + if errSet := jsCtx.Set("protocol", protocol); errSet != nil { + return bodyStr, nil, false, errSet + } + if errSet := jsCtx.Set("headers", headerToAnyMap(respHeaders)); errSet != nil { + return bodyStr, nil, false, errSet + } + if isStream { + if chunkStr != nil { + if errSet := jsCtx.Set("chunk", *chunkStr); errSet != nil { + return bodyStr, nil, false, errSet + } + } else { + if errSet := jsCtx.Set("chunk", ""); errSet != nil { + return bodyStr, nil, false, errSet + } + } + historyChunksValue, errHistory := engine.frozenStringArray(historyChunks) + if errHistory != nil { + return bodyStr, nil, false, fmt.Errorf("failed to freeze history_chunks: %w", errHistory) + } + if errDefine := jsCtx.DefineDataProperty("history_chunks", historyChunksValue, goja.FLAG_FALSE, goja.FLAG_FALSE, goja.FLAG_TRUE); errDefine != nil { + return bodyStr, nil, false, fmt.Errorf("failed to define history_chunks: %w", errDefine) + } + } else { + if errSet := jsCtx.Set("chunk", nil); errSet != nil { + return bodyStr, nil, false, errSet + } + if errSet := jsCtx.Set("history_chunks", nil); errSet != nil { + return bodyStr, nil, false, errSet + } + } + + hookName := "on_after_nonstream_response" + if isStream { + hookName = "on_after_stream_response" + } + jsVal, errCall := engine.callFunction(hookName, p.cfg.Timeout, jsCtx) + if errCall != nil { + if errors.Is(errCall, ErrFunctionNotFound) { + return bodyStr, nil, false, nil + } + return bodyStr, nil, false, fmt.Errorf("%s failed for %s: %w", hookName, scriptPath, errCall) + } + + if jsVal == nil || goja.IsUndefined(jsVal) || goja.IsNull(jsVal) { + return bodyStr, nil, false, nil + } + + exported := jsVal.Export() + if exported == nil { + return bodyStr, nil, false, nil + } + + var headersResult *processedHeaders + if objMap, ok := exported.(map[string]any); ok { + if headersVal, exists := objMap["headers"]; exists { + cleared := updateHeaderFromAny(respHeaders, headersVal) + headersResult = &processedHeaders{headers: respHeaders, clearHeaders: cleared} + } + if !isStream { + if bodyVal, exists := objMap["body"]; exists { + if bStr, okStr := bodyVal.(string); okStr { + return bStr, headersResult, true, nil + } + } + } else { + if chunkVal, exists := objMap["chunk"]; exists { + if cStr, okStr := chunkVal.(string); okStr { + return cStr, headersResult, true, nil + } + } + } + } + + if strVal, ok := exported.(string); ok { + return strVal, headersResult, true, nil + } + + return bodyStr, headersResult, false, nil +} + +func headerToAnyMap(h http.Header) map[string]any { + m := make(map[string]any) + if h == nil { + return m + } + for k, v := range h { + switch len(v) { + case 0: + continue + case 1: + m[k] = v[0] + default: + m[k] = append([]string(nil), v...) + } + } + return m +} + +func updateHeaderFromAny(h http.Header, val interface{}) []string { + var clearHeaders []string + if h == nil || val == nil { + return clearHeaders + } + rv := reflect.ValueOf(val) + if rv.Kind() != reflect.Map { + return clearHeaders + } + for _, key := range rv.MapKeys() { + kStr := key.String() + vVal := rv.MapIndex(key).Interface() + if vVal == nil { + h.Del(kStr) + clearHeaders = append(clearHeaders, kStr) + } else if valStr, ok := vVal.(string); ok { + h.Set(kStr, valStr) + } else { + values, okValues := stringSliceFromAny(vVal) + if !okValues { + h.Set(kStr, fmt.Sprintf("%v", vVal)) + continue + } + if len(values) == 0 { + h.Del(kStr) + clearHeaders = append(clearHeaders, kStr) + } else { + h[http.CanonicalHeaderKey(kStr)] = values + } + } + } + return clearHeaders +} + +func cloneHeader(h http.Header) http.Header { + cloned := make(http.Header, len(h)) + for key, values := range h { + cloned[key] = append([]string(nil), values...) + } + return cloned +} + +func stringSliceFromAny(val any) ([]string, bool) { + switch typed := val.(type) { + case []string: + return append([]string(nil), typed...), true + case []any: + values := make([]string, 0, len(typed)) + for _, item := range typed { + itemStr, okItem := item.(string) + if !okItem { + return nil, false + } + values = append(values, itemStr) + } + return values, true + } + + rv := reflect.ValueOf(val) + if rv.Kind() != reflect.Slice && rv.Kind() != reflect.Array { + return nil, false + } + values := make([]string, 0, rv.Len()) + for i := 0; i < rv.Len(); i++ { + item, okItem := rv.Index(i).Interface().(string) + if !okItem { + return nil, false + } + values = append(values, item) + } + return values, true +} + +func dedupeStrings(values []string) []string { + if len(values) == 0 { + return nil + } + seen := make(map[string]struct{}, len(values)) + deduped := make([]string, 0, len(values)) + for _, value := range values { + canonical := http.CanonicalHeaderKey(value) + if _, exists := seen[canonical]; exists { + continue + } + seen[canonical] = struct{}{} + deduped = append(deduped, canonical) + } + return deduped +} + +func generateRequestID() string { + return fmt.Sprintf("%s-%x", time.Now().Format("20060102150405"), time.Now().UnixNano()&0xffffffff) +} diff --git a/examples/plugin/jshandler/interceptor_test.go b/examples/plugin/jshandler/interceptor_test.go new file mode 100644 index 00000000..8a310812 --- /dev/null +++ b/examples/plugin/jshandler/interceptor_test.go @@ -0,0 +1,100 @@ +package main + +import ( + "net/http" + "os" + "path/filepath" + "testing" +) + +func TestApplyJSAfterResponseUsesFrozenNativeHistoryChunks(t *testing.T) { + scriptPath := filepath.Join(t.TempDir(), "stream.js") + script := ` +function on_after_stream_response(ctx) { + if (!Object.isFrozen(ctx.history_chunks)) { + throw new Error("history_chunks is not frozen"); + } + var original = ctx.history_chunks[0]; + try { + ctx.history_chunks[0] = "changed"; + } catch (e) { + } + if (ctx.history_chunks[0] !== original) { + throw new Error("history_chunks item was changed"); + } + try { + ctx.history_chunks = ["changed"]; + } catch (e) { + } + if (ctx.history_chunks[0] !== original) { + throw new Error("history_chunks property was replaced"); + } + return { chunk: ctx.chunk + "|ok" }; +} +` + if errWrite := os.WriteFile(scriptPath, []byte(script), 0600); errWrite != nil { + t.Fatalf("os.WriteFile() error = %v", errWrite) + } + + plugin := &jsHandlerPlugin{cfg: defaultJSHandlerConfig()} + chunk := `data: {"choices":[{"delta":{},"finish_reason":null}]}` + processedBody, _, changed, errApply := plugin.applyJSAfterResponse( + scriptPath, + "gpt-test", + "openai", + nil, + nil, + "", + &chunk, + http.Header{}, + true, + []string{`data: {"choices":[{"delta":{"tool_calls":[{"index":0}]}}]}`}, + ) + if errApply != nil { + t.Fatalf("applyJSAfterResponse() error = %v", errApply) + } + if !changed { + t.Fatal("applyJSAfterResponse() changed = false, want true") + } + if processedBody != chunk+"|ok" { + t.Fatalf("applyJSAfterResponse() body = %q, want %q", processedBody, chunk+"|ok") + } +} + +func TestApplyJSAfterResponseDispatchesNonStreamHook(t *testing.T) { + scriptPath := filepath.Join(t.TempDir(), "nonstream.js") + script := ` +function on_after_stream_response(ctx) { + throw new Error("stream hook should not run"); +} +function on_after_nonstream_response(ctx) { + return { body: ctx.body + "|nonstream" }; +} +` + if errWrite := os.WriteFile(scriptPath, []byte(script), 0600); errWrite != nil { + t.Fatalf("os.WriteFile() error = %v", errWrite) + } + + plugin := &jsHandlerPlugin{cfg: defaultJSHandlerConfig()} + processedBody, _, changed, errApply := plugin.applyJSAfterResponse( + scriptPath, + "gpt-test", + "openai", + nil, + nil, + `{"ok":true}`, + nil, + http.Header{}, + false, + nil, + ) + if errApply != nil { + t.Fatalf("applyJSAfterResponse() error = %v", errApply) + } + if !changed { + t.Fatal("applyJSAfterResponse() changed = false, want true") + } + if processedBody != `{"ok":true}|nonstream` { + t.Fatalf("applyJSAfterResponse() body = %q", processedBody) + } +} diff --git a/examples/plugin/jshandler/main.go b/examples/plugin/jshandler/main.go new file mode 100644 index 00000000..06358a7a --- /dev/null +++ b/examples/plugin/jshandler/main.go @@ -0,0 +1,50 @@ +package main + +import ( + "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" +) + +func buildPlugin(configYAML []byte, pluginDir string) (pluginapi.Plugin, error) { + cfg, errParse := parseJSHandlerConfig(configYAML) + if errParse != nil { + return pluginapi.Plugin{}, errParse + } + if pluginDir == "" { + pluginDir = inferPluginDir() + } + p := &jsHandlerPlugin{ + cfg: cfg, + configYAML: append([]byte(nil), configYAML...), + pluginDir: pluginDir, + } + return pluginapi.Plugin{ + Metadata: pluginapi.Metadata{ + Name: pluginName, + Version: "0.1.0", + Author: "router-for-me", + GitHubRepository: "https://github.com/router-for-me/CLIProxyAPI", + ConfigFields: []pluginapi.ConfigField{ + { + Name: "enabled", + Type: pluginapi.ConfigFieldTypeBoolean, + Description: "Enable or disable the JS handler plugin.", + }, + { + Name: "script_paths", + Type: pluginapi.ConfigFieldTypeArray, + Description: "List of JS script file paths to load (absolute or relative to plugin directory).", + }, + { + Name: "timeout", + Type: pluginapi.ConfigFieldTypeString, + Description: "Execution timeout per JS hook call as a Go duration, such as 1s.", + }, + }, + }, + Capabilities: pluginapi.Capabilities{ + RequestInterceptor: p, + ResponseInterceptor: p, + StreamChunkInterceptor: p, + }, + }, nil +} diff --git a/examples/plugin/jshandler/scripts/copilot_handler.js b/examples/plugin/jshandler/scripts/copilot_handler.js new file mode 100644 index 00000000..6d50fff2 --- /dev/null +++ b/examples/plugin/jshandler/scripts/copilot_handler.js @@ -0,0 +1,116 @@ +function on_before_request(ctx) { + try { + var req = JSON.parse(ctx.body); + console.log("[" + ctx.id + "] message: " + ctx.body); + if (req.messages) { + for (var i = 0; i < req.messages.length; i++) { + if (typeof req.messages[i].content === "string") { + req.messages[i].content = req.messages[i].content.replace("sensitive_word", "safe_word"); + } + } + } + ctx.body = JSON.stringify(req); + console.log("[" + ctx.id + "] message: " + ctx.body); + } catch (e) { + console.log("[" + ctx.id + "] Failed to parse request JSON, skipping payload modification: " + e.message); + } + return ctx; +} + +function parse_stream_chunk(chunk) { + var leading = ""; + var payload = chunk.trim(); + var trailing = ""; + + var dataIndex = chunk.indexOf("data:"); + if (dataIndex >= 0) { + leading = chunk.substring(0, dataIndex) + "data:"; + var afterData = chunk.substring(dataIndex + 5); + var newlineIndex = afterData.indexOf("\n"); + if (newlineIndex >= 0) { + payload = afterData.substring(0, newlineIndex).trim(); + trailing = afterData.substring(newlineIndex); + } else { + payload = afterData.trim(); + } + } + + if (payload === "" || payload === "[DONE]") { + return null; + } + + return { + obj: JSON.parse(payload), + leading: leading, + trailing: trailing + }; +} + +function stringify_stream_chunk(parsed) { + if (parsed.leading !== "") { + return parsed.leading + " " + JSON.stringify(parsed.obj) + parsed.trailing; + } + return JSON.stringify(parsed.obj); +} + +function on_after_stream_response(ctx) { + console.log("[" + ctx.id + "] Received response with status: " + ctx.status); + if (ctx.chunk === undefined || ctx.chunk === null || ctx.chunk === "") { + return ctx; + } + + try { + var parsed = parse_stream_chunk(ctx.chunk); + if (parsed === null) { + return ctx; + } + var obj = parsed.obj; + if (obj.choices && obj.choices.length > 0) { + var choice = obj.choices[0]; + var has_tool_calls = choice.delta && choice.delta.tool_calls && choice.delta.tool_calls.length > 0; + + if (has_tool_calls) { + if (choice.finish_reason !== null) { + console.log("[" + ctx.id + "] Tool call chunk has finish_reason = [" + choice.finish_reason + "], forcing reset to null, tool index: " + choice.delta.tool_calls[0].index); + choice.finish_reason = null; + ctx.chunk = stringify_stream_chunk(parsed); + } + } else { + var history_had_tool_calls = false; + if (ctx.history_chunks && ctx.history_chunks.length > 0) { + for (var i = 0; i < ctx.history_chunks.length; i++) { + try { + var h_parsed = parse_stream_chunk(ctx.history_chunks[i]); + if (h_parsed === null) { + continue; + } + var hist_obj = h_parsed.obj; + if (hist_obj.choices && hist_obj.choices.length > 0) { + var h_choice = hist_obj.choices[0]; + if (h_choice.delta && h_choice.delta.tool_calls && h_choice.delta.tool_calls.length > 0) { + history_had_tool_calls = true; + break; + } + } + } catch (err) { + } + } + } + + if (history_had_tool_calls && choice.finish_reason !== null && choice.finish_reason !== "tool_calls") { + console.log("[" + ctx.id + "] Detected history contains tool calls, modifying finish_reason from [" + choice.finish_reason + "] to [tool_calls]"); + choice.finish_reason = "tool_calls"; + ctx.chunk = stringify_stream_chunk(parsed); + } + } + } + } catch (e) { + console.log("[" + ctx.id + "] Failed to parse streaming response JSON chunk: " + e.message + " | chunk content: " + ctx.chunk); + } + return ctx; +} + +function on_after_nonstream_response(ctx) { + console.log("[" + ctx.id + "] Received non-streaming response. Response content: " + ctx.body); + return ctx; +}