Files
magpie/internal/plugin/host.go
T
yetone b63a0f1fd5 plugins: a plugin's provider takes more than one account, as a built-in subscription does (the user: 插件当然要支持多账号了) — a plugin kept one sign-in per provider, and signing in again replaced it. Now each sign-in goes beside the others: plugin-auth.json keeps the first under the provider's id, as OpenCode does (so what is signed in already stays the first account), the rest under id#slot; logins.json lists them as it lists the built-ins' accounts, so the editor shows them all, one first, the others ticked on behind it or off, any made first, any removed (which signs it out). The host runs every request in its account's scope, so a plugin's getAuth, client.auth.set (a refreshed token), loader and provider.models see that account alone; a sign-in to an account already there (the same id, else the same secret) replaces that one's rather than listing it twice. The gateway spreads and fails over across the accounts on, as for the built-ins. Accounts with no id are told apart by the end of their key ("API key …abcd"), else by their slot. Also on the Devin page: the header shows the plugin's icon, and Cognition's opencode-devin is off the market (its gateway answers 404 model_not_found for models and chat). TestPluginAccounts (plugin: per-account requests and refreshes, dedupe, sign-out; provider: first, on/off, switch, forget, labels), TestPluginProvider (gateway: a 429 on the first account goes on the second) and plugin-signin.test.cjs (add a second account in the editor, make it first; fails without the change) are new. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass; plugin-signin, plugin-market, old-webkit, click-scroll pass.
2026-09-30 19:58:22 +08:00

507 lines
11 KiB
Go

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"`
Account string `json:"account,omitempty"` // the provider's first when ""
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
}