package plugin import ( "bufio" "context" "crypto/sha256" _ "embed" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "io" "log" "net/http" "os" "os/exec" "path/filepath" "sync" "sync/atomic" "time" "github.com/yetone/magpie/internal/catalog" "github.com/yetone/magpie/internal/settings" ) //go:embed host.js var hostJS []byte // message is a line the host writes. type message struct { ID int64 `json:"id"` Result json.RawMessage `json:"result"` Error *struct { Message string `json:"message"` } `json:"error"` Event string `json:"event"` Status int `json:"status"` Headers map[string]string `json:"headers"` Data string `json:"data"` Provider string `json:"provider"` Level string `json:"level"` Message string `json:"message"` Title string `json:"title"` Variant string `json:"variant"` } type call struct { done chan message // the answer events chan message // a fetch's head and chunks, before its answer gone chan struct{} // closed when no one reads events any more closed atomic.Bool // done has been answered } // host is one Bun process running host.js. type host struct { cmd *exec.Cmd in io.WriteCloser wmu sync.Mutex mu sync.Mutex calls map[int64]*call next int64 dead chan struct{} err error loaded []Loaded } // Loaded is how a plugin fared when the host loaded it. type Loaded struct { Spec string `json:"spec"` Error string `json:"error,omitempty"` } var ( hostMu sync.Mutex current *host // generation counts the hosts started, so a caller can tell a // restart happened (the provider cache is then stale) generation atomic.Int64 // onChange are told a sign-in or the plugins changed. onChange []func() onChangeMu sync.Mutex ) // OnChange registers f to be told when a plugin sign-in or the plugins // change (a sign-in saved or refreshed, a plugin added). func OnChange(f func()) { onChangeMu.Lock() onChange = append(onChange, f) onChangeMu.Unlock() } func changed() { forgetProviders() onChangeMu.Lock() fs := append([]func(){}, onChange...) onChangeMu.Unlock() for _, f := range fs { go f() } } // Restart stops the host, so the next call starts one with the plugins // as they are now. func Restart() { hostMu.Lock() h := current current = nil hostMu.Unlock() if h != nil { h.stop() } changed() } // Running is whether a host is up. func Running() bool { hostMu.Lock() defer hostMu.Unlock() return current != nil && current.alive() } func (h *host) alive() bool { select { case <-h.dead: return false default: return true } } func (h *host) stop() { h.in.Close() select { case <-h.dead: case <-time.After(2 * time.Second): if h.cmd.Process != nil { h.cmd.Process.Kill() } } } // hostFile is host.js written where Bun can run it. func hostFile() (string, error) { sum := sha256.Sum256(hostJS) dir := filepath.Join(filepath.Dir(catalog.CachePath()), "plugin-host") p := filepath.Join(dir, "host-"+hex.EncodeToString(sum[:6])+".js") if b, err := os.ReadFile(p); err == nil && string(b) == string(hostJS) { return p, nil } if err := os.MkdirAll(dir, 0o755); err != nil { return "", err } tmp := p + ".tmp" if err := os.WriteFile(tmp, hostJS, 0o644); err != nil { return "", err } return p, os.Rename(tmp, p) } // get is the running host, started (Bun downloaded, the plugins loaded) // when there is none. func get(ctx context.Context) (*host, error) { hostMu.Lock() defer hostMu.Unlock() if current != nil && current.alive() { return current, nil } h, err := start(ctx) if err != nil { return nil, err } current = h return h, nil } func start(ctx context.Context) (*host, error) { bun, err := Bun(ctx) if err != nil { return nil, err } js, err := hostFile() if err != nil { return nil, err } if err := os.MkdirAll(settings.Dir(), 0o700); err != nil { return nil, err } if catalog.Source() == "" { // a plugin's provider has the models models.dev lists for it, as in // OpenCode; a magpie that has never fetched them does first cctx, cancel := context.WithTimeout(ctx, 30*time.Second) _ = catalog.Sync(cctx) cancel() } // the host outlives the request that started it cmd := bunCommand(context.Background(), bun, settings.Dir(), "run", js) in, err := cmd.StdinPipe() if err != nil { return nil, err } out, err := cmd.StdoutPipe() if err != nil { return nil, err } stderr, err := cmd.StderrPipe() if err != nil { return nil, err } if err := cmd.Start(); err != nil { return nil, err } h := &host{cmd: cmd, in: in, calls: map[int64]*call{}, dead: make(chan struct{})} go func() { sc := bufio.NewScanner(stderr) sc.Buffer(make([]byte, 64<<10), 4<<20) for sc.Scan() { log.Printf("plugin: %s", sc.Text()) } }() go h.read(out) generation.Add(1) l := Load() type item struct { Spec string `json:"spec"` Target string `json:"target"` Options map[string]any `json:"options,omitempty"` } var items []item for _, e := range l.Plugins { if !e.Off { items = append(items, item{e.Spec, Target(e.Spec), e.Options}) } } var res struct { Plugins []Loaded `json:"plugins"` } ictx, cancel := context.WithTimeout(ctx, 2*time.Minute) defer cancel() err = h.call(ictx, "init", map[string]any{ "authPath": AuthPath(), "modelsDevPath": catalog.Source(), "directory": settings.Dir(), "config": l.Config, "plugins": items, }, &res) if err != nil { h.stop() return nil, fmt.Errorf("starting plugins: %w", err) } h.loaded = res.Plugins for _, p := range res.Plugins { if p.Error != "" { log.Printf("plugin %s didn't load: %s", p.Spec, p.Error) } } return h, nil } func (h *host) read(out io.Reader) { r := bufio.NewReaderSize(out, 1<<20) for { line, err := r.ReadBytes('\n') if len(line) > 0 { var m message if json.Unmarshal(line, &m) == nil { h.dispatch(m) } } if err != nil { break } } err := h.cmd.Wait() h.mu.Lock() if err == nil { err = errors.New("the plugin host quit") } h.err = fmt.Errorf("the plugin host quit: %v", err) calls := h.calls h.calls = map[int64]*call{} h.mu.Unlock() close(h.dead) for _, c := range calls { if c.closed.CompareAndSwap(false, true) { c.done <- message{Error: &struct { Message string `json:"message"` }{h.err.Error()}} } } } func (h *host) dispatch(m message) { if m.ID == 0 { switch m.Event { case "auth": changed() case "toast": log.Printf("plugin: %s %s", m.Title, m.Message) case "log": log.Printf("plugin [%s]: %s", m.Level, m.Message) } return } h.mu.Lock() c := h.calls[m.ID] if m.Event == "" { delete(h.calls, m.ID) } h.mu.Unlock() if c == nil { return } if m.Event != "" { if c.events != nil { select { case c.events <- m: case <-c.gone: } } return } if c.closed.CompareAndSwap(false, true) { c.done <- m } } func (h *host) send(v any) error { b, err := json.Marshal(v) if err != nil { return err } h.wmu.Lock() defer h.wmu.Unlock() _, err = h.in.Write(append(b, '\n')) return err } func (h *host) begin(events bool) (int64, *call) { c := &call{done: make(chan message, 1), gone: make(chan struct{})} if events { c.events = make(chan message, 64) } h.mu.Lock() h.next++ id := h.next h.calls[id] = c h.mu.Unlock() return id, c } func (h *host) forget(id int64) { h.mu.Lock() delete(h.calls, id) h.mu.Unlock() } // call asks the host method with params and reads its answer into out. func (h *host) call(ctx context.Context, method string, params, out any) error { id, c := h.begin(false) if err := h.send(map[string]any{"id": id, "method": method, "params": params}); err != nil { h.forget(id) return err } select { case m := <-c.done: if m.Error != nil { return errors.New(m.Error.Message) } if out != nil && len(m.Result) > 0 { return json.Unmarshal(m.Result, out) } return nil case <-ctx.Done(): h.forget(id) return ctx.Err() } } // Call starts the host if need be and asks it method. func Call(ctx context.Context, method string, params, out any) error { h, err := get(ctx) if err != nil { return err } return h.call(ctx, method, params, out) } // Plugins is how each plugin fared when the host loaded it, starting the // host if need be. func Plugins(ctx context.Context) ([]Loaded, error) { h, err := get(ctx) if err != nil { return nil, err } return h.loaded, nil } // FetchRequest is a request a provider's plugin makes. type FetchRequest struct { Provider string `json:"provider"` Model string `json:"model"` NPM string `json:"npm"` URL string `json:"url"` Method string `json:"method"` Headers map[string]string `json:"headers"` Body []byte `json:"-"` Session string `json:"session,omitempty"` } // Fetch sends r through the provider's plugin — its loader's fetch, or // Bun's own when it gives none — and streams back the reply. func Fetch(ctx context.Context, r FetchRequest) (*http.Response, error) { h, err := get(ctx) if err != nil { return nil, err } id, c := h.begin(true) params := struct { FetchRequest Body string `json:"body,omitempty"` }{r, base64.StdEncoding.EncodeToString(r.Body)} if err := h.send(map[string]any{"id": id, "method": "fetch", "params": params}); err != nil { h.forget(id) return nil, err } abort := func() { _ = h.send(map[string]any{"method": "abort", "params": map[string]any{"id": id}}) } var head message select { case head = <-c.events: case m := <-c.done: if m.Error != nil { return nil, errors.New(m.Error.Message) } return nil, errors.New("the plugin answered no response") case <-ctx.Done(): abort() h.forget(id) return nil, ctx.Err() } pr, pw := io.Pipe() res := &http.Response{ StatusCode: head.Status, Status: fmt.Sprintf("%d %s", head.Status, http.StatusText(head.Status)), Header: http.Header{}, Body: pr, Proto: "HTTP/1.1", ProtoMajor: 1, ProtoMinor: 1, } for k, v := range head.Headers { res.Header.Set(k, v) } // the body is decoded already: fetch takes the Content-Encoding off res.Header.Del("Content-Encoding") res.Header.Del("Content-Length") go func() { defer close(c.gone) write := func(data string) error { b, err := base64.StdEncoding.DecodeString(data) if err != nil { return err } _, err = pw.Write(b) return err } for { select { case m := <-c.events: if err := write(m.Data); err != nil { // the reader went away, or the chunk is garbled abort() h.forget(id) pw.CloseWithError(err) return } case m := <-c.done: // the chunks came before the answer: any still queued // are written first drain: for { select { case e := <-c.events: if write(e.Data) != nil { break drain } default: break drain } } if m.Error != nil { pw.CloseWithError(errors.New(m.Error.Message)) } else { pw.Close() } return case <-ctx.Done(): abort() h.forget(id) pw.CloseWithError(ctx.Err()) return } } }() return res, nil }