settings, gateway: the gateway's port is set in Settings › Network › Gateway, and every agent magpie connected moves with it (Magic_zero on Discord: 能否添加自定义端口号 — 3425 is taken on their machine, and they want a port of their own that is easy to remember and tell apart)
Before this, only MAGPIE_ADDR could move the gateway, and the app on a Mac or one started from the Start menu has no easy way to set an env var. The setting: - Settings has a Gateway port field. Any port from 1024 to 65535 can be typed, then applied with Apply or Enter. - The port is saved in settings.json and kept on this computer (KeepOwn), so a backup or WebDAV doesn't carry it across. - MAGPIE_ADDR, where it is set, still comes first. The field is then off and says so. - A port out of range is refused in the page. - A port another program listens on is refused: "port N is in use by another program: pick another one". The gateway stays where it was. - With another magpie serving the gateway, the field says to set the port there. On a change: - The listener moves live (Relisten listens on the new port before closing the old one). - When the old port had been taken by another program and nothing served, the gateway starts on the new one. - Every agent connected to magpie has its config rewritten with the new URL (agent.OnGateway before the move, agent.Rewire after): - each one whose wiring is now "unwired" is set again; - Codex signed in with ChatGPT is joined again and keeps its own model; - Cursor Private Inference's variables and dsh's routes follow. - What each agent had before magpie (the stash) is kept through this. Before, setting an agent again on the old URL would have written magpie's old URL and token over it. - The page says how many agents moved. What follows the saved port (through gateway.Addr()/URL() and settings.GatewayAddr): - the CLI and healthcheck; - Running(); - LAN sharing (Port); - the drift check for the installed magpie, from beside a MAGPIE_ADDR one; - importapps' "is this entry magpie's own" check. zh, ja and de strings. Tests, all failing on the old code: - TestPortMovesEveryAgent: every agent with a config file is connected at 3591 and moved to 3592. No file keeps 3591, and none is off afterwards. - TestPortMovesJoinedCodex. - TestPortLeavesAnUnwiredAgent. - TestPortKeepsWhatAnAgentHadBefore: the relay URL and own token come back on disconnect. - TestDriftInstalledMagpieOnItsPort. - TestSetPortMovesTheGateway, through the API: taken, out of range, moved, the old port silent, POST /api/settings keeps it, MAGPIE_ADDR refused. - TestSetPortServesWhenTheOldOneWasTaken. - TestGatewayURLOnSettingsPort. - gateway-port.test.cjs on WebKit, en and zh: - out of range, taken (translated), applied by Enter, the agents counted, the new URL shown; - the MAGPIE_ADDR state; - no select, no left border, and the page never scrolls (the row is swapped whole, or WebKit laid the list out empty and the view jumped). Sandbox run (own HOME, settings port 3591, magpie web on 3593): - Claude Code (over a relay with its own token) and Codex were connected at 3591. - POST /api/settings/port: - 3594, held by another program: refused, in use; - 80: refused; - 3592: moved [Claude Code, Codex]. - Both configs then say 3592. The gateway answers on 3592 and not on 3591, and `magpie healthcheck` exits 0. - `magpie claude default` gave back https://relay.example and own-token. lan-docker, settings-sections, settings-groups, click-scroll, gateway-caller-keys, gui-ja, gui-de and webdav-settings-refresh pass on WebKit. go vet, the builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.
This commit is contained in:
1 parent
653cb8ee30
commit
2880b95b60
19 files changed
+1006
-22
No files matched your search
+2
-1
@@ -14,7 +14,8 @@ import (
|
||||
)
|
||||
|
||||
// healthcheck: magpie healthcheck — exits 0 when a magpie gateway answers
|
||||
// at MAGPIE_ADDR (127.0.0.1:3425 unless set), for a container's
|
||||
// at its address (MAGPIE_ADDR, else Settings' Gateway port on 127.0.0.1,
|
||||
// 3425 unless set), for a container's
|
||||
// HEALTHCHECK, which has no curl to ask it with.
|
||||
func healthcheck() error {
|
||||
c := &http.Client{Timeout: 3 * time.Second}
|
||||
|
||||
@@ -180,11 +180,12 @@ func (a *Agent) Drift() *Drift {
|
||||
}
|
||||
|
||||
// installedURL is the gateway of the magpie people install, on its own
|
||||
// port; a magpie on another one (magpie-dev, a sandbox) is run beside it.
|
||||
var installedURL = "http://" + gateway.DefaultAddr
|
||||
// port, the one its Settings have; a magpie MAGPIE_ADDR puts on another
|
||||
// (magpie-dev, a sandbox) is run beside it.
|
||||
var installedURL = gateway.SavedURL
|
||||
|
||||
// elsewhere: this magpie is not on the installed one's port.
|
||||
func elsewhere() bool { return !sameHost(gateway.URL(), installedURL) }
|
||||
func elsewhere() bool { return !sameHost(gateway.URL(), installedURL()) }
|
||||
|
||||
// theInstalled: this magpie runs beside the installed one, and the agent is
|
||||
// wired to that one's gateway, not this one's — the installed magpie set it
|
||||
@@ -198,7 +199,7 @@ func (a *Agent) theInstalled() bool {
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(string(b), gateway.DefaultAddr) && !strings.Contains(string(b), hostOf(gateway.URL()))
|
||||
return strings.Contains(string(b), hostOf(installedURL())) && !strings.Contains(string(b), hostOf(gateway.URL()))
|
||||
}
|
||||
|
||||
// bypassed: the agent was used — while this gateway was up and after magpie
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// The gateway's port is set in Settings (Magic_zero on Discord): every
|
||||
// agent magpie connected has the gateway's URL written into its config, so
|
||||
// moving the gateway moves them too. Before the port changes, OnGateway
|
||||
// notes which agents reach the gateway where it is; once it has moved,
|
||||
// Rewire sets each of them again, which writes the URL it has now.
|
||||
|
||||
// OnGateway are the agents connected to magpie whose configs reach the
|
||||
// gateway at its address now: none of their wiring is off.
|
||||
func OnGateway() []*Agent {
|
||||
var out []*Agent
|
||||
for _, a := range All() {
|
||||
if len(a.Fields) == 0 || !a.Wired() {
|
||||
continue
|
||||
}
|
||||
if d := a.Drift(); d != nil && d.Kind == "unwired" {
|
||||
continue // taken off magpie before: the user's to set again
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Rewire puts the gateway's address now into the configs of agents, taken
|
||||
// by OnGateway before it moved: each one whose wiring is off now is set
|
||||
// again, an agent joined to magpie beside its own models (Codex signed in
|
||||
// with ChatGPT) joined again. It says the names of those it moved, and
|
||||
// what one it couldn't move failed with. Cursor Private Inference's
|
||||
// variables and dsh's routes, which magpie keeps on its own, follow too.
|
||||
func Rewire(as []*Agent) (moved []string, err error) {
|
||||
var errs []error
|
||||
for _, a := range as {
|
||||
d := a.Drift()
|
||||
if d == nil || d.Kind != "unwired" {
|
||||
continue
|
||||
}
|
||||
// set again on the old URL, an agent reads as not routed through
|
||||
// magpie yet, and its set notes the old URL, magpie's token and
|
||||
// model as what it had before magpie: what it really had is kept,
|
||||
// for switching it off to put back
|
||||
was := stashLoad()
|
||||
e := reset(a)
|
||||
stashPut(was)
|
||||
if e != nil {
|
||||
errs = append(errs, fmt.Errorf("%s: %w", a.Name, e))
|
||||
continue
|
||||
}
|
||||
if a.Sync != nil {
|
||||
_ = a.Sync()
|
||||
}
|
||||
moved = append(moved, a.Name)
|
||||
}
|
||||
KeepCursorLocalEnv(context.Background())
|
||||
if trouble := dshWiredOnce(); trouble != "" {
|
||||
errs = append(errs, fmt.Errorf("%s", trouble))
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
err = errs[0]
|
||||
for _, e := range errs[1:] {
|
||||
err = fmt.Errorf("%w; %w", err, e)
|
||||
}
|
||||
}
|
||||
return moved, err
|
||||
}
|
||||
|
||||
// reset sets an agent's wiring again: one joined beside its own models
|
||||
// joined again, any other's fields set again.
|
||||
func reset(a *Agent) error {
|
||||
if a.Join != nil && a.Joined != nil && a.Joined() {
|
||||
ok, err := a.Join()
|
||||
if err != nil || ok {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return a.Reapply()
|
||||
}
|
||||
|
||||
// stashPut writes the stash as m has it.
|
||||
func stashPut(m map[string]string) {
|
||||
b, _ := json.MarshalIndent(m, "", " ")
|
||||
os.MkdirAll(filepath.Dir(stashPath()), 0o755)
|
||||
os.WriteFile(stashPath(), b, 0o600)
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/yetone/magpie/internal/edit"
|
||||
"github.com/yetone/magpie/internal/gateway"
|
||||
"github.com/yetone/magpie/internal/settings"
|
||||
)
|
||||
|
||||
// setPort is Settings' gateway port set to p, as the Settings page saves it.
|
||||
func setPort(t *testing.T, p int) {
|
||||
t.Helper()
|
||||
s := settings.Load()
|
||||
s.Port = p
|
||||
if err := settings.Save(s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// filesWith are the files under home holding s.
|
||||
func filesWith(home, s string) []string {
|
||||
var out []string
|
||||
filepath.WalkDir(home, func(p string, e os.DirEntry, err error) error {
|
||||
if err != nil || e.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if b, _ := os.ReadFile(p); strings.Contains(string(b), s) {
|
||||
out = append(out, p)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// The gateway's port set in Settings (Magic_zero on Discord) moves every
|
||||
// agent magpie connected: each one's config is written with the gateway's
|
||||
// URL on the new port, none keeps the old one, and each is still connected
|
||||
// with nothing off.
|
||||
func TestPortMovesEveryAgent(t *testing.T) {
|
||||
t.Setenv("MAGPIE_ADDR", "")
|
||||
home, _ := codexHome(t, "", "")
|
||||
managed := claudeManaged
|
||||
claudeManaged = func() string { return filepath.Join(home, "managed-settings.json") }
|
||||
t.Cleanup(func() { claudeManaged = managed })
|
||||
almaApp := startAlma(t)
|
||||
os.MkdirAll(filepath.Join(home, ".hanako", "agents", "hana"), 0o755)
|
||||
os.WriteFile(filepath.Join(home, ".hanako", "agents", "hana", "config.yaml"), []byte("agent:\n name: Hana\n"), 0o644)
|
||||
setPort(t, 3591)
|
||||
if gateway.URL() != "http://127.0.0.1:3591" {
|
||||
t.Fatalf("Settings' port isn't the gateway's: %s", gateway.URL())
|
||||
}
|
||||
var wired []*Agent
|
||||
for _, a := range All() {
|
||||
// as TestDriftUnwiredEveryAgent: agy's gateway is in the command
|
||||
// that starts it, and a WSL agent's files are beyond the sandbox
|
||||
if a.Check == nil || a.Launch != nil || a.WSL != "" {
|
||||
continue
|
||||
}
|
||||
var key, want string
|
||||
for _, g := range a.Fields {
|
||||
for _, o := range g.Options(a.Values()) {
|
||||
if want == "" && (o.Ref == "fake/m1" || o.Value == magpieID) {
|
||||
key, want = g.Key, o.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
if want == "" {
|
||||
t.Fatalf("%s: no field takes magpie's models", a.ID)
|
||||
}
|
||||
if err := a.Apply(key, want); err != nil {
|
||||
t.Fatalf("%s: %v", a.ID, err)
|
||||
}
|
||||
wired = append(wired, a)
|
||||
}
|
||||
if len(filesWith(home, "127.0.0.1:3591")) == 0 {
|
||||
t.Fatal("no agent's config has the gateway's URL")
|
||||
}
|
||||
on := OnGateway()
|
||||
for _, a := range wired {
|
||||
if !containsAgent(on, a.ID) {
|
||||
t.Errorf("%s is connected but not on the gateway: %+v", a.ID, a.Drift())
|
||||
}
|
||||
}
|
||||
|
||||
setPort(t, 3592)
|
||||
moved, err := Rewire(on)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// OpenChamber reads OpenCode's config, which moves with OpenCode
|
||||
if len(moved) < len(wired)-1 {
|
||||
t.Errorf("moved %d of %d: %v", len(moved), len(wired), moved)
|
||||
}
|
||||
if left := filesWith(home, "127.0.0.1:3591"); len(left) > 0 {
|
||||
t.Errorf("still on the old port: %v", left)
|
||||
}
|
||||
if almaApp.repoint("127.0.0.1:3591", "127.0.0.1:3591") > 0 {
|
||||
t.Error("Alma still has the old port")
|
||||
}
|
||||
for _, a := range wired {
|
||||
if !a.Wired() {
|
||||
t.Errorf("%s isn't connected after the move", a.ID)
|
||||
}
|
||||
if d := a.Drift(); d != nil {
|
||||
t.Errorf("%s is off after the move: %+v", a.ID, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func containsAgent(as []*Agent, id string) bool {
|
||||
for _, a := range as {
|
||||
if a.ID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Codex signed in with ChatGPT is joined to magpie beside its own models:
|
||||
// the move writes its base URL on the new port and keeps the model it was
|
||||
// on, rather than putting it on one of magpie's.
|
||||
func TestPortMovesJoinedCodex(t *testing.T) {
|
||||
t.Setenv("MAGPIE_ADDR", "")
|
||||
home, read := codexHome(t, `{"auth_mode":"chatgpt","tokens":{"access_token":"at","refresh_token":"rt","id_token":"x.e30.x","account_id":"acc"}}`, "model = \"gpt-5.1\"\n")
|
||||
setPort(t, 3591)
|
||||
cx := codex(home)
|
||||
c, err := cx.ConnectHow()
|
||||
if err != nil || c.How != "joined" {
|
||||
t.Fatalf("not joined: %+v %v\n%s", c, err, read())
|
||||
}
|
||||
on := OnGateway()
|
||||
setPort(t, 3592)
|
||||
if _, err := Rewire(on); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := read()
|
||||
if !strings.Contains(cfg, "127.0.0.1:3592") || strings.Contains(cfg, "127.0.0.1:3591") {
|
||||
t.Fatalf("Codex not moved:\n%s", cfg)
|
||||
}
|
||||
if !strings.Contains(cfg, `model = "gpt-5.1"`) || !cx.Joined() {
|
||||
t.Fatalf("Codex's own model lost:\n%s", cfg)
|
||||
}
|
||||
if d := cx.Drift(); d != nil {
|
||||
t.Fatalf("off after the move: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// An agent taken off magpie before the port moved is left as it is: it is
|
||||
// the user's to set again, not the move's.
|
||||
func TestPortLeavesAnUnwiredAgent(t *testing.T) {
|
||||
t.Setenv("MAGPIE_ADDR", "")
|
||||
home, read := codexHome(t, "", "")
|
||||
setPort(t, 3591)
|
||||
cx := codex(home)
|
||||
if err := cx.Apply("model", "fake/m1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := filepath.Join(home, ".codex", "config.toml")
|
||||
os.WriteFile(cfg, []byte(strings.ReplaceAll(read(), "127.0.0.1:3591", "127.0.0.1:9")), 0o644)
|
||||
on := OnGateway()
|
||||
setPort(t, 3592)
|
||||
if _, err := Rewire(on); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(read(), "127.0.0.1:3592") {
|
||||
t.Fatalf("an agent taken off magpie was moved:\n%s", read())
|
||||
}
|
||||
}
|
||||
|
||||
// A magpie MAGPIE_ADDR puts beside the installed one tells the installed
|
||||
// one's wiring by the port the installed one's Settings have, not 3425.
|
||||
func TestDriftInstalledMagpieOnItsPort(t *testing.T) {
|
||||
home, _ := codexHome(t, "", "")
|
||||
setPort(t, 3591)
|
||||
t.Setenv("MAGPIE_ADDR", "127.0.0.1:3426")
|
||||
cx := codex(home)
|
||||
if err := cx.Apply("model", "fake/m1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := filepath.Join(home, ".codex", "config.toml")
|
||||
os.WriteFile(cfg, []byte("openai_base_url = \"http://127.0.0.1:3591"+gateway.CodexPath+"\"\nmodel = \"fake/m1\"\n"), 0o644)
|
||||
if d := cx.Drift(); d != nil {
|
||||
t.Fatalf("the installed magpie's wiring taken for drift: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// Moved, an agent still puts back what it had before magpie when switched
|
||||
// off: its own endpoint and token, not magpie's at the old port, which
|
||||
// setting it again there would otherwise have noted as its own.
|
||||
func TestPortKeepsWhatAnAgentHadBefore(t *testing.T) {
|
||||
t.Setenv("MAGPIE_ADDR", "")
|
||||
home, _ := codexHome(t, "", "")
|
||||
managed := claudeManaged
|
||||
claudeManaged = func() string { return filepath.Join(home, "managed-settings.json") }
|
||||
t.Cleanup(func() { claudeManaged = managed })
|
||||
path := filepath.Join(home, ".claude", "settings.json")
|
||||
os.MkdirAll(filepath.Dir(path), 0o755)
|
||||
os.WriteFile(path, []byte(`{"env":{"ANTHROPIC_BASE_URL":"https://relay.example","ANTHROPIC_AUTH_TOKEN":"own-token"}}`), 0o644)
|
||||
setPort(t, 3591)
|
||||
cc := claude(home)
|
||||
var want string
|
||||
for _, o := range cc.Field("model").Options(cc.Values()) {
|
||||
if o.Ref == "fake/m1" {
|
||||
want = o.Value
|
||||
}
|
||||
}
|
||||
if err := cc.Apply("model", want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
on := OnGateway()
|
||||
setPort(t, 3592)
|
||||
if _, err := Rewire(on); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if u, _ := edit.GetJSON(path, "env.ANTHROPIC_BASE_URL"); u != "http://127.0.0.1:3592" {
|
||||
t.Fatalf("Claude Code not moved: %q", u)
|
||||
}
|
||||
if err := cc.Disconnect(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u, _ := edit.GetJSON(path, "env.ANTHROPIC_BASE_URL")
|
||||
tok, _ := edit.GetJSON(path, "env.ANTHROPIC_AUTH_TOKEN")
|
||||
if u != "https://relay.example" || tok != "own-token" {
|
||||
t.Fatalf("switched off, Claude Code got %q %q back, not its own", u, tok)
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,8 @@ import (
|
||||
"github.com/yetone/magpie/internal/usage"
|
||||
)
|
||||
|
||||
// DefaultAddr is where the gateway listens unless MAGPIE_ADDR says otherwise.
|
||||
// DefaultAddr is where the gateway listens unless Settings' port or
|
||||
// MAGPIE_ADDR says otherwise.
|
||||
const DefaultAddr = "127.0.0.1:3425"
|
||||
|
||||
// Token is the bearer token agents are told to use. The gateway only
|
||||
@@ -174,13 +175,13 @@ func switchedOff(p provider.Provider, model string) string {
|
||||
// Version is set by main.
|
||||
var Version = "dev"
|
||||
|
||||
// Addr is the listen address.
|
||||
func Addr() string {
|
||||
if a := os.Getenv("MAGPIE_ADDR"); a != "" {
|
||||
return a
|
||||
}
|
||||
return DefaultAddr
|
||||
}
|
||||
// Addr is the listen address: MAGPIE_ADDR, else Settings' port on
|
||||
// loopback (settings.GatewayAddr).
|
||||
func Addr() string { return settings.GatewayAddr() }
|
||||
|
||||
// SavedURL is URL by Settings alone, MAGPIE_ADDR left aside: where the
|
||||
// installed magpie, whose settings these are, is reached.
|
||||
func SavedURL() string { return "http://" + settings.SavedAddr() }
|
||||
|
||||
// URL is the base URL agents use, e.g. http://127.0.0.1:3425: a gateway
|
||||
// listening on every interface is reached here on loopback.
|
||||
|
||||
+17
-2
@@ -9,6 +9,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@@ -35,7 +36,7 @@ func listenAddr() string {
|
||||
func Port() string {
|
||||
_, p, err := net.SplitHostPort(Addr())
|
||||
if err != nil {
|
||||
return "3425"
|
||||
return strconv.Itoa(settings.DefaultPort)
|
||||
}
|
||||
return p
|
||||
}
|
||||
@@ -147,7 +148,8 @@ func LANURLs() []string {
|
||||
}
|
||||
|
||||
// Relisten moves the gateway to where settings now say it listens — onto
|
||||
// the network or back to loopback. Requests in flight finish.
|
||||
// the network or back to loopback, or to another port. Requests in flight
|
||||
// finish.
|
||||
func (s *Server) Relisten() error {
|
||||
migrateLANKeyBestEffort()
|
||||
s.lnMu.Lock()
|
||||
@@ -160,6 +162,19 @@ func (s *Server) Relisten() error {
|
||||
return nil
|
||||
}
|
||||
was := s.ln.Addr().String()
|
||||
// another port (Settings' port changed): the new one is taken up before
|
||||
// the old one lets go, so one the gateway can't have leaves it serving
|
||||
// where it was
|
||||
if _, p, _ := net.SplitHostPort(was); p != Port() {
|
||||
ln, err := Listen(to)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
old := s.ln
|
||||
s.ln = ln
|
||||
old.Close()
|
||||
return nil
|
||||
}
|
||||
// the port is the same, so the old one goes first
|
||||
s.ln.Close()
|
||||
ln, err := Listen(to)
|
||||
|
||||
@@ -286,6 +286,8 @@ type settingsJSON struct {
|
||||
Version string `json:"version"`
|
||||
Dir string `json:"dir"` // where magpie keeps its files, as shown
|
||||
Gateway string `json:"gateway"` // the local endpoint
|
||||
// MAGPIE_ADDR, when it sets the gateway's address over Settings' port
|
||||
AddrEnv string `json:"addrEnv,omitempty"`
|
||||
// Dir is the data folder beside a portable magpie (#508)
|
||||
Portable bool `json:"portable,omitempty"`
|
||||
// Mac apps that explicitly handle .command files, for resumed sessions.
|
||||
@@ -426,6 +428,7 @@ func searchState(s *settingsJSON) {
|
||||
|
||||
func settingsState() settingsJSON {
|
||||
s := settingsJSON{Settings: settings.Load(), Version: Version, Dir: tilde(settings.Dir()), Portable: settings.Portable() != "", Gateway: gateway.URL()}
|
||||
s.AddrEnv = os.Getenv("MAGPIE_ADDR")
|
||||
s.LANKey = "" // the retained credential belongs on disk, not in UI state
|
||||
// the GitHub token, masked, and where the library's requests take one
|
||||
// from: Settings, or the environment variable named
|
||||
@@ -844,6 +847,7 @@ func Handler(w Windows, gw *gateway.Server) http.Handler {
|
||||
settings.CarryPerModel(&in, &cur)
|
||||
in.LAN, in.LANKey = cur.LAN, cur.LANKey
|
||||
in.LANKeyID = cur.LANKeyID
|
||||
in.Port = cur.Port // set on its own (port below), which moves the gateway
|
||||
in.GitHubToken = cur.GitHubToken // set on its own (github-token below), never sent to the page
|
||||
in.RequestArchive = cur.RequestArchive // the Gateway page's, set on its own
|
||||
in.RequestArchiveMaxMB = cur.RequestArchiveMaxMB // in settings.json only
|
||||
@@ -1181,6 +1185,21 @@ func Handler(w Windows, gw *gateway.Server) http.Handler {
|
||||
}
|
||||
writeJSON(rw, settingsState())
|
||||
})
|
||||
// the gateway's port (Magic_zero on Discord), set on its own: it moves
|
||||
// the gateway and every agent connected to it
|
||||
mux.HandleFunc("POST /api/settings/port", func(rw http.ResponseWriter, r *http.Request) {
|
||||
var in struct{ Port int }
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
fail(rw, err)
|
||||
return
|
||||
}
|
||||
res, err := setPort(in.Port)
|
||||
if err != nil {
|
||||
fail(rw, err)
|
||||
return
|
||||
}
|
||||
writeJSON(rw, map[string]any{"settings": settingsState(), "port": res})
|
||||
})
|
||||
// the user's own masking rules, all of them each time: set on their own,
|
||||
// so a pattern that doesn't compile is said and the rest are kept (#195)
|
||||
mux.HandleFunc("POST /api/settings/redact-rules", func(rw http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -2300,7 +2300,7 @@ input.words { width: 240px; max-width: 100%; }
|
||||
.row .sub.wraps { white-space: normal; overflow-wrap: anywhere; }
|
||||
.prefs .val.rule-add { flex: 1 1 100%; max-width: none; margin-left: 0; gap: 6px; }
|
||||
input.words.rule-kind { flex: none; width: 110px; }
|
||||
input.words.rule-match { flex: 1 1 auto; width: auto; min-width: 0; }
|
||||
input.words.gateway-port { flex: none; width: 110px; font-variant-numeric: tabular-nums; }input.words.rule-match { flex: 1 1 auto; width: auto; min-width: 0; }
|
||||
input.words.search-key, input.words.search-url { flex: 1 1 120px; width: auto; min-width: 0; }
|
||||
/* a search API's key, shown: selectable, and broken anywhere rather than widening the row */
|
||||
.search-api .sub.revealed { font-family: var(--code); overflow-wrap: anywhere; user-select: text; -webkit-user-select: text; }
|
||||
|
||||
@@ -15221,6 +15221,7 @@ function renderSettings() {
|
||||
renderReplies(s, keep);
|
||||
renderRedact(s, keep);
|
||||
renderOTel(s, keep);
|
||||
renderPort(s);
|
||||
renderLAN(s);
|
||||
renderSync();
|
||||
|
||||
@@ -16343,6 +16344,69 @@ function renderRedactRules(s, row) {
|
||||
});
|
||||
}
|
||||
|
||||
// renderPort: the gateway's port on this computer (Magic_zero on Discord:
|
||||
// 3425 taken by another program, or one easier to tell apart). Any port
|
||||
// from 1024 up can be typed; magpie moves its gateway there and every
|
||||
// agent it connected with it, or says why it can't (another program has
|
||||
// it). MAGPIE_ADDR, set, comes first and the field says so.
|
||||
let portDraft = { value: null, err: "" };
|
||||
// portSays is the gateway's word on a port it couldn't take, in the page's
|
||||
// language: it says the port, so it is matched rather than looked up
|
||||
function portSays(m) {
|
||||
let x;
|
||||
if ((x = /^port (\d+) is in use by another program/.exec(m))) return t("Port {p} is in use by another program: pick another one", { p: x[1] });
|
||||
if ((x = /^another magpie serves the gateway at (\S+):/.exec(m))) return t("Another magpie serves the gateway at {url}: set its port there, or quit it first", { url: x[1] });
|
||||
if (/from 1024 to 65535/.test(m)) return t("A port is a number from 1024 to 65535");
|
||||
return t(m);
|
||||
}
|
||||
function renderPort(s) {
|
||||
const box = $("#portList");
|
||||
const r = el("div", "row pref port-row");
|
||||
const who = el("div", "who");
|
||||
who.append(el("div", "name", t("Gateway port")));
|
||||
const sub = el("div", "sub");
|
||||
const d = portDraft;
|
||||
const now = s.addrEnv ? "" : String(s.port || 3425);
|
||||
const field = input(d.value ?? now, "3425");
|
||||
field.className = "words gateway-port";
|
||||
field.inputMode = "numeric";
|
||||
field.setAttribute("aria-label", t("Gateway port"));
|
||||
const save = el("button", "text", t("Apply"));
|
||||
if (s.addrEnv) {
|
||||
field.value = s.addrEnv;
|
||||
field.disabled = save.disabled = true;
|
||||
sub.textContent = t("MAGPIE_ADDR={addr} sets the gateway’s address: unset it to set the port here", { addr: s.addrEnv });
|
||||
} else if (d.err) {
|
||||
sub.textContent = d.err;
|
||||
sub.classList.add("err");
|
||||
} else sub.textContent = t("Agents magpie connected move with it. Now {url}", { url: s.gateway || "" });
|
||||
who.append(sub);
|
||||
save.onclick = () => {
|
||||
const p = Number(field.value.trim());
|
||||
if (!Number.isInteger(p) || p < 1024 || p > 65535) {
|
||||
d.value = field.value; d.err = t("A port is a number from 1024 to 65535");
|
||||
return renderPort(s);
|
||||
}
|
||||
if (p === (s.port || 3425)) { d.value = null; d.err = ""; return renderPort(s); }
|
||||
save.disabled = true;
|
||||
writingPrefs(api("settings/port", { port: p }).then((res) => res.settings && (prefs = res.settings, res)))
|
||||
.then((res) => {
|
||||
portDraft = { value: null, err: "" };
|
||||
const n = res.port?.moved?.length || 0;
|
||||
if (res.port?.error) status(res.port.error, "err");
|
||||
else status(n ? t("Gateway on port {p}; {n} agents moved with it", { p, n }) : t("Gateway on port {p}", { p }), "ok", 2500);
|
||||
renderSettings();
|
||||
})
|
||||
.catch((e) => { d.value = field.value; d.err = portSays(e.message); status(d.err, "err"); renderSettings(); });
|
||||
};
|
||||
field.oninput = () => { d.value = field.value; };
|
||||
field.onkeydown = (e) => { e.stopPropagation(); if (e.key === "Enter") save.onclick(); if (e.key === "Escape") { portDraft = { value: null, err: "" }; renderPort(s); } };
|
||||
const val = el("div", "val");
|
||||
val.append(field, save);
|
||||
r.append(who, val);
|
||||
box.replaceChildren(r); // swapped whole: the list is never laid out empty
|
||||
}
|
||||
|
||||
// renderLAN: the gateway shared on the local network, for agents on other
|
||||
// machines. Gateway keys and connection examples live together in Gateway.
|
||||
let lanSelectedURL = "", lanProtocol = "openai";
|
||||
|
||||
@@ -386,6 +386,14 @@ const I18N = {
|
||||
"Mask secrets": "脱敏密钥",
|
||||
"Local network": "局域网",
|
||||
"Share on local network": "局域网共享",
|
||||
"Gateway port": "网关端口",
|
||||
"Agents magpie connected move with it. Now {url}": "改了之后,已接入 magpie 的 Agent 会一起换过去。当前 {url}",
|
||||
"MAGPIE_ADDR={addr} sets the gateway’s address: unset it to set the port here": "MAGPIE_ADDR={addr} 已指定网关地址:去掉这个环境变量后才能在这里设端口",
|
||||
"A port is a number from 1024 to 65535": "端口是 1024 到 65535 之间的数字",
|
||||
"Port {p} is in use by another program: pick another one": "端口 {p} 已被其他程序占用,请换一个",
|
||||
"Another magpie serves the gateway at {url}: set its port there, or quit it first": "另一个 magpie 正在 {url} 提供网关:请在那边设端口,或先退出它",
|
||||
"Gateway on port {p}; {n} agents moved with it": "网关已换到端口 {p},{n} 个 Agent 已跟着换过去",
|
||||
"Gateway on port {p}": "网关已换到端口 {p}",
|
||||
"{label} address": "{label} 地址",
|
||||
"Copied {label} address": "已复制 {label} 地址",
|
||||
"Agents on other computers on this network can use magpie’s models with a gateway key from Gateway": "同一网络中其他电脑上的 Agent 可凭网关页的网关密钥使用 magpie 的模型",
|
||||
@@ -3499,6 +3507,14 @@ const I18N = {
|
||||
"Mask secrets": "シークレットをマスク",
|
||||
"Local network": "ローカルネットワーク",
|
||||
"Share on local network": "ローカルネットワークで共有",
|
||||
"Gateway port": "ゲートウェイのポート",
|
||||
"Agents magpie connected move with it. Now {url}": "magpie に接続したエージェントも一緒に移ります。現在 {url}",
|
||||
"MAGPIE_ADDR={addr} sets the gateway’s address: unset it to set the port here": "MAGPIE_ADDR={addr} がゲートウェイのアドレスを指定しています。ここでポートを設定するには解除してください",
|
||||
"A port is a number from 1024 to 65535": "ポートは 1024〜65535 の数値です",
|
||||
"Port {p} is in use by another program: pick another one": "ポート {p} は他のプログラムが使用中です。別のポートを選んでください",
|
||||
"Another magpie serves the gateway at {url}: set its port there, or quit it first": "別の magpie が {url} でゲートウェイを提供しています。そちらでポートを設定するか、先に終了してください",
|
||||
"Gateway on port {p}; {n} agents moved with it": "ゲートウェイをポート {p} に移しました。{n} 個のエージェントも移りました",
|
||||
"Gateway on port {p}": "ゲートウェイをポート {p} に移しました",
|
||||
"{label} address": "{label} のアドレス",
|
||||
"Copied {label} address": "{label} のアドレスをコピーしました",
|
||||
"Agents on other computers on this network can use magpie’s models with a gateway key from Gateway": "このネットワーク上の他のコンピュータのエージェントは、ゲートウェイのゲートウェイキーを使って magpie のモデルを利用できます",
|
||||
@@ -6593,6 +6609,14 @@ const I18N = {
|
||||
"Mask secrets": "Geheimnisse maskieren",
|
||||
"Local network": "Lokales Netzwerk",
|
||||
"Share on local network": "Im lokalen Netzwerk freigeben",
|
||||
"Gateway port": "Port des Zugangspunkts",
|
||||
"Agents magpie connected move with it. Now {url}": "Mit magpie verbundene Agenten ziehen mit um. Jetzt {url}",
|
||||
"MAGPIE_ADDR={addr} sets the gateway’s address: unset it to set the port here": "MAGPIE_ADDR={addr} legt die Adresse des Zugangspunkts fest: entferne die Variable, um den Port hier zu setzen",
|
||||
"A port is a number from 1024 to 65535": "Ein Port ist eine Zahl von 1024 bis 65535",
|
||||
"Port {p} is in use by another program: pick another one": "Port {p} wird von einem anderen Programm verwendet: wähle einen anderen",
|
||||
"Another magpie serves the gateway at {url}: set its port there, or quit it first": "Ein anderes magpie stellt den Zugangspunkt unter {url} bereit: setze den Port dort oder beende es zuerst",
|
||||
"Gateway on port {p}; {n} agents moved with it": "Zugangspunkt auf Port {p}; {n} Agenten sind mitgezogen",
|
||||
"Gateway on port {p}": "Zugangspunkt auf Port {p}",
|
||||
"{label} address": "{label}-Adresse",
|
||||
"Copied {label} address": "{label}-Adresse kopiert",
|
||||
"Agents on other computers on this network can use magpie’s models with a gateway key from Gateway": "Agenten auf anderen Computern in diesem Netzwerk können die magpie-Modelle mit einem Zugangsschlüssel aus „Zugangspunkt“ nutzen",
|
||||
|
||||
@@ -380,6 +380,8 @@
|
||||
</div>
|
||||
<div class="row-head"><span class="label">GitHub</span></div>
|
||||
<div class="list prefs" id="githubList"></div>
|
||||
<div class="row-head"><span class="label" data-t>Gateway</span></div>
|
||||
<div class="list prefs" id="portList"></div>
|
||||
<div class="row-head"><span class="label" data-t>Local network</span></div>
|
||||
<div class="list prefs" id="lanList"></div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/yetone/magpie/internal/agent"
|
||||
"github.com/yetone/magpie/internal/gateway"
|
||||
"github.com/yetone/magpie/internal/settings"
|
||||
)
|
||||
|
||||
// portJSON is what setting the gateway's port did: the port it is on, the
|
||||
// agents whose configs were moved with it, and what one couldn't be.
|
||||
type portJSON struct {
|
||||
Port int `json:"port"`
|
||||
Moved []string `json:"moved,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// portFree says whether the gateway could listen at addr: nothing else
|
||||
// does. A var so tests can say a port is taken.
|
||||
var portFree = func(addr string) error {
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ln.Close()
|
||||
}
|
||||
|
||||
// errPortTaken is a port another program listens on.
|
||||
func errPortTaken(p int) error {
|
||||
return fmt.Errorf("port %d is in use by another program: pick another one", p)
|
||||
}
|
||||
|
||||
// setPort moves the gateway to port p (0 for the default) — Settings'
|
||||
// Gateway port (Magic_zero on Discord: 3425 was taken, and a port of one's
|
||||
// own is easier to tell apart): it checks nothing listens there, saves it,
|
||||
// moves the gateway this magpie serves (or starts it, when the port it was
|
||||
// on was taken), and then every agent connected to magpie, whose configs
|
||||
// have the gateway's URL in them.
|
||||
func setPort(p int) (portJSON, error) {
|
||||
if err := settings.CheckPort(p); err != nil {
|
||||
return portJSON{}, err
|
||||
}
|
||||
if a := os.Getenv("MAGPIE_ADDR"); a != "" {
|
||||
return portJSON{}, fmt.Errorf("MAGPIE_ADDR=%s sets the gateway's address, which comes before Settings: unset it to set the port here", a)
|
||||
}
|
||||
want := p
|
||||
if want == 0 {
|
||||
want = settings.DefaultPort
|
||||
}
|
||||
cur := settings.Load()
|
||||
was := cur.Port
|
||||
if strconv.Itoa(want) == gateway.Port() {
|
||||
if was != p {
|
||||
cur.Port = p
|
||||
if err := settings.Save(cur); err != nil {
|
||||
return portJSON{}, err
|
||||
}
|
||||
}
|
||||
return portJSON{Port: want}, nil
|
||||
}
|
||||
gw := served.Load()
|
||||
if gw == nil && gateway.Running() {
|
||||
return portJSON{}, fmt.Errorf("another magpie serves the gateway at %s: set its port there, or quit it first", gateway.URL())
|
||||
}
|
||||
host := "127.0.0.1"
|
||||
if cur.LAN {
|
||||
host = "0.0.0.0"
|
||||
}
|
||||
if err := portFree(net.JoinHostPort(host, strconv.Itoa(want))); err != nil {
|
||||
return portJSON{}, errPortTaken(want)
|
||||
}
|
||||
// the agents reaching the gateway where it is now, to move with it
|
||||
on := agent.OnGateway()
|
||||
cur.Port = p
|
||||
if err := settings.Save(cur); err != nil {
|
||||
return portJSON{}, err
|
||||
}
|
||||
if gw != nil {
|
||||
if err := gw.Relisten(); err != nil {
|
||||
back := settings.Load()
|
||||
back.Port = was
|
||||
_ = settings.Save(back)
|
||||
return portJSON{}, errPortTaken(want)
|
||||
}
|
||||
} else {
|
||||
// the port it was on was taken by something else: it serves now
|
||||
serveGateway()
|
||||
}
|
||||
out := portJSON{Port: want}
|
||||
moved, err := agent.Rewire(on)
|
||||
out.Moved = moved
|
||||
if err != nil {
|
||||
out.Error = err.Error()
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/yetone/magpie/internal/gateway"
|
||||
"github.com/yetone/magpie/internal/settings"
|
||||
)
|
||||
|
||||
// freePort is a port nothing listens on now.
|
||||
func freePort(t *testing.T) int {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
return ln.Addr().(*net.TCPAddr).Port
|
||||
}
|
||||
|
||||
// answers says whether a magpie gateway answers on port p.
|
||||
func answers(p int) bool {
|
||||
c := &http.Client{Timeout: time.Second}
|
||||
res, err := c.Get(fmt.Sprintf("http://127.0.0.1:%d/", p))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
res.Body.Close()
|
||||
return res.StatusCode == http.StatusOK
|
||||
}
|
||||
|
||||
// Settings' gateway port (Magic_zero on Discord) moves the gateway this
|
||||
// magpie serves: it answers on the new port and no longer on the old one,
|
||||
// and magpie's URL, the CLI's and the agents', follows. A port another
|
||||
// program has is refused with its reason, the gateway left where it was;
|
||||
// one out of range or under MAGPIE_ADDR is refused too.
|
||||
func TestSetPortMovesTheGateway(t *testing.T) {
|
||||
sandboxHome(t)
|
||||
t.Setenv("MAGPIE_ADDR", "")
|
||||
from := freePort(t)
|
||||
s := settings.Load()
|
||||
s.Port = from
|
||||
if err := settings.Save(s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gateway.URL() != fmt.Sprintf("http://127.0.0.1:%d", from) {
|
||||
t.Fatalf("Settings' port isn't the gateway's: %s", gateway.URL())
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
gw := gateway.New()
|
||||
done := make(chan struct{})
|
||||
go func() { defer close(done); gw.ListenAndServe(ctx) }()
|
||||
served.Store(gw)
|
||||
t.Cleanup(func() { served.Store(nil); cancel(); <-done })
|
||||
for deadline := time.Now().Add(5 * time.Second); !answers(from); time.Sleep(20 * time.Millisecond) {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("the gateway didn't start")
|
||||
}
|
||||
}
|
||||
|
||||
handler := Handler(nil, nil)
|
||||
post := func(body string) (int, map[string]any) {
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/settings/port", strings.NewReader(body)))
|
||||
var out map[string]any
|
||||
json.Unmarshal(w.Body.Bytes(), &out)
|
||||
return w.Code, out
|
||||
}
|
||||
|
||||
// a port another program listens on
|
||||
taken, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer taken.Close()
|
||||
tp := taken.Addr().(*net.TCPAddr).Port
|
||||
code, out := post(fmt.Sprintf(`{"port":%d}`, tp))
|
||||
if code != http.StatusBadRequest || !strings.Contains(fmt.Sprint(out["error"]), "in use") {
|
||||
t.Fatalf("a taken port: %d %v", code, out)
|
||||
}
|
||||
if settings.Load().Port != from || !answers(from) {
|
||||
t.Fatal("a taken port moved the gateway")
|
||||
}
|
||||
for _, bad := range []int{80, 70000} {
|
||||
if code, out := post(fmt.Sprintf(`{"port":%d}`, bad)); code != http.StatusBadRequest || !strings.Contains(fmt.Sprint(out["error"]), "1024 to 65535") {
|
||||
t.Fatalf("port %d: %d %v", bad, code, out)
|
||||
}
|
||||
}
|
||||
|
||||
to := freePort(t)
|
||||
code, out = post(fmt.Sprintf(`{"port":%d}`, to))
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("moving: %d %v", code, out)
|
||||
}
|
||||
if p, _ := out["port"].(map[string]any); p == nil || p["port"] != float64(to) {
|
||||
t.Fatalf("the answer: %v", out)
|
||||
}
|
||||
if st, _ := out["settings"].(map[string]any); st["gateway"] != fmt.Sprintf("http://127.0.0.1:%d", to) || st["port"] != float64(to) {
|
||||
t.Fatalf("Settings say: %v", st)
|
||||
}
|
||||
if !answers(to) {
|
||||
t.Fatal("the gateway doesn't answer on the new port")
|
||||
}
|
||||
if answers(from) {
|
||||
t.Fatal("the gateway still answers on the old port")
|
||||
}
|
||||
if !gateway.Running() {
|
||||
t.Fatal("magpie doesn't find its own gateway on the new port")
|
||||
}
|
||||
|
||||
// the Settings page's other choices, saved, leave the port as it is
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/settings", strings.NewReader(`{"theme":"dark"}`)))
|
||||
if w.Code != http.StatusOK || settings.Load().Port != to {
|
||||
t.Fatalf("saving Settings took the port: %d %s, port %d", w.Code, w.Body, settings.Load().Port)
|
||||
}
|
||||
|
||||
// MAGPIE_ADDR comes first: the port is set there, not here
|
||||
t.Setenv("MAGPIE_ADDR", "127.0.0.1:3426")
|
||||
if code, out := post(fmt.Sprintf(`{"port":%d}`, freePort(t))); code != http.StatusBadRequest || !strings.Contains(fmt.Sprint(out["error"]), "MAGPIE_ADDR") {
|
||||
t.Fatalf("under MAGPIE_ADDR: %d %v", code, out)
|
||||
}
|
||||
w = httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/settings", nil))
|
||||
if !strings.Contains(w.Body.String(), `"addrEnv":"127.0.0.1:3426"`) {
|
||||
t.Fatalf("Settings don't say MAGPIE_ADDR: %s", w.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// The port magpie was on taken by another program (Magic_zero's 3425), it
|
||||
// serves nothing: setting another port starts the gateway there.
|
||||
func TestSetPortServesWhenTheOldOneWasTaken(t *testing.T) {
|
||||
sandboxHome(t)
|
||||
t.Setenv("MAGPIE_ADDR", "")
|
||||
other, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer other.Close()
|
||||
srv := &http.Server{Handler: http.NotFoundHandler()}
|
||||
go srv.Serve(other)
|
||||
defer srv.Close()
|
||||
s := settings.Load()
|
||||
s.Port = other.Addr().(*net.TCPAddr).Port
|
||||
if err := settings.Save(s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
served.Store(nil)
|
||||
t.Cleanup(func() { served.Store(nil) })
|
||||
if gateway.Running() {
|
||||
t.Fatal("another program taken for magpie")
|
||||
}
|
||||
to := freePort(t)
|
||||
if _, err := setPort(to); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for deadline := time.Now().Add(5 * time.Second); !answers(to); time.Sleep(20 * time.Millisecond) {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("the gateway didn't start on the new port")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,7 +124,8 @@ func TestSettingsSaveKeepsWhatItDoesNotSend(t *testing.T) {
|
||||
LAN: true,
|
||||
LANKey: "sk-lan",
|
||||
LANKeyID: "lan-key-id",
|
||||
GitHubToken: "ghp_kept",
|
||||
Port: 3591,
|
||||
GitHubToken: "ghp_kept",
|
||||
RequestArchive: true,
|
||||
RequestArchiveMaxMB: 64,
|
||||
QuotaLeft: true,
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
// Run with Node's test runner and Playwright on the module path; see README.md.
|
||||
// Settings › Gateway port (Magic_zero on Discord: 3425 was taken by another
|
||||
// program, and a port of one's own is easier to tell apart): any port from
|
||||
// 1024 to 65535 can be typed and applied. One out of range is refused in the
|
||||
// page; one another program has is refused with the gateway's reason, in the
|
||||
// page's language, what was typed kept; a free one moves the gateway and
|
||||
// says how many agents moved with it, and the row then says the new URL.
|
||||
// With MAGPIE_ADDR set the field is off and says why. The field is an
|
||||
// input, not a native <select>; applying never scrolls the page; no
|
||||
// coloured left border. English and Chinese, Chromium and WebKit; no
|
||||
// backend, the API is faked here.
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { test } = require("node:test");
|
||||
const { chromium, webkit } = require("playwright");
|
||||
|
||||
const assets = path.resolve(__dirname, "../assets");
|
||||
|
||||
function settingsPayload(over) {
|
||||
return {
|
||||
theme: "light", lang: "en", tray: "panel", quotaLeft: false, currency: "usd",
|
||||
dock: false, dockWindow: false, proxy: "", redact: false, redactPersonal: false, redactWords: [],
|
||||
codexWarmup: "", claudeWarmup: "", codexWarmAt: "", claudeWarmAt: "", workbuddyCheckin: false, noStats: false,
|
||||
trayUsage: "", trayUsageEvery: 3, vision: "", imageGen: "",
|
||||
version: "0.1.900", dir: "/config/magpie", gateway: "http://127.0.0.1:3425",
|
||||
proxyNow: "none", proxySource: "none", login: false,
|
||||
visionModels: [], imageGenModels: [], workbuddyCheckins: [],
|
||||
lan: false, lanURLs: [],
|
||||
fx: { rate: 7.2, at: new Date().toISOString(), stale: false },
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function server(lang, over, posts) {
|
||||
let s = settingsPayload({ lang, ...over });
|
||||
return async (route) => {
|
||||
const req = route.request();
|
||||
const url = new URL(req.url());
|
||||
const json = (data, status = 200) => route.fulfill({ status, json: data });
|
||||
if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:false};` });
|
||||
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
|
||||
if (url.pathname === "/api/state") return json({ agents: [], profiles: [], settings: { lang, theme: "light" }, fx: s.fx });
|
||||
if (url.pathname === "/api/settings/port") {
|
||||
const { port } = JSON.parse(req.postData());
|
||||
posts.push(port);
|
||||
if (port === 3500) return json({ error: "port 3500 is in use by another program: pick another one" }, 400);
|
||||
s = { ...s, port, gateway: "http://127.0.0.1:" + port };
|
||||
return json({ settings: s, port: { port, moved: ["Claude Code", "Codex"] } });
|
||||
}
|
||||
if (url.pathname === "/api/settings") return json(s);
|
||||
if (url.pathname === "/api/plugins") return json({ plugins: [] });
|
||||
if (url.pathname === "/api/usage/quotas") return json([]);
|
||||
if (url.pathname === "/api/groups") return json({ groups: [], models: [] });
|
||||
if (url.pathname.startsWith("/api/")) return json({});
|
||||
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
|
||||
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
|
||||
await route.fulfill({ body: await fs.readFile(file), contentType });
|
||||
};
|
||||
}
|
||||
|
||||
const words = {
|
||||
en: {
|
||||
name: "Gateway port", apply: "Apply",
|
||||
now: (u) => "Agents magpie connected move with it. Now " + u,
|
||||
range: "A port is a number from 1024 to 65535",
|
||||
taken: "Port 3500 is in use by another program: pick another one",
|
||||
moved: "Gateway on port 3591; 2 agents moved with it",
|
||||
env: "MAGPIE_ADDR=127.0.0.1:3426 sets the gateway’s address: unset it to set the port here",
|
||||
},
|
||||
zh: {
|
||||
name: "网关端口", apply: "应用",
|
||||
now: (u) => "改了之后,已接入 magpie 的 Agent 会一起换过去。当前 " + u,
|
||||
range: "端口是 1024 到 65535 之间的数字",
|
||||
taken: "端口 3500 已被其他程序占用,请换一个",
|
||||
moved: "网关已换到端口 3591,2 个 Agent 已跟着换过去",
|
||||
env: "MAGPIE_ADDR=127.0.0.1:3426 已指定网关地址:去掉这个环境变量后才能在这里设端口",
|
||||
},
|
||||
};
|
||||
|
||||
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
|
||||
test(engine + ": the gateway's port is typed in Settings and agents move with it", async (t) => {
|
||||
assert(["chromium", "webkit"].includes(engine), "BROWSER must be chromium or webkit");
|
||||
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
|
||||
const pages = [];
|
||||
t.after(async () => {
|
||||
if (process.env.ARTIFACT_DIR) {
|
||||
await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true });
|
||||
for (const [i, p] of pages.entries()) await p.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-gateway-port-${i}.png`) });
|
||||
}
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
const open = async (lang, over) => {
|
||||
const page = await (await browser.newContext({ viewport: { width: 900, height: 360 }, reducedMotion: "reduce" })).newPage();
|
||||
pages.push(page);
|
||||
page.setDefaultTimeout(5000);
|
||||
const errors = [], posts = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
await page.route("**/*", server(lang, over, posts));
|
||||
await page.goto("http://localhost:3430/?view=settings&tab=network");
|
||||
await page.locator("#portList .port-row").waitFor();
|
||||
return { page, errors, posts };
|
||||
};
|
||||
|
||||
for (const lang of ["en", "zh"]) {
|
||||
const w = words[lang];
|
||||
await t.test(lang, async () => {
|
||||
{
|
||||
const { page, errors, posts } = await open(lang);
|
||||
const row = page.locator("#portList .port-row");
|
||||
const field = row.locator("input.gateway-port");
|
||||
const sub = row.locator(".sub");
|
||||
const apply = row.getByRole("button", { name: w.apply });
|
||||
assert.equal((await row.locator(".name").textContent()).trim(), w.name);
|
||||
assert.equal(await field.inputValue(), "3425");
|
||||
assert.equal((await sub.textContent()).trim(), w.now("http://127.0.0.1:3425"));
|
||||
assert.equal(await page.locator("#portList select").count(), 0, "no native select");
|
||||
const border = await row.evaluate((r) => getComputedStyle(r).borderLeftWidth);
|
||||
assert(border === "0px" || border === "", "no left border accent: " + border);
|
||||
|
||||
// scrolled with the wheel, as a reader would, until the row is in view
|
||||
const scroll = () => page.locator("#view-settings").evaluate((v) => v.scrollTop);
|
||||
await page.locator("#view-settings").hover();
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const box = await apply.boundingBox();
|
||||
if (box && box.y > 80 && box.y + box.height < 340 && (await scroll()) > 0) break;
|
||||
await page.mouse.wheel(0, 120);
|
||||
await page.waitForTimeout(30);
|
||||
}
|
||||
await page.waitForTimeout(500); // the wheel's scroll come to rest
|
||||
const before = await scroll();
|
||||
assert(before > 0, "the settings must be scrolled to the row");
|
||||
|
||||
// out of range: said in the page, nothing sent
|
||||
await field.fill("80");
|
||||
assert.equal(await scroll(), before, "typing moved the page");
|
||||
await apply.click();
|
||||
await page.waitForFunction((want) => document.querySelector("#portList .sub")?.textContent.trim() === want, w.range);
|
||||
assert(await row.locator(".sub").evaluate((e) => e.classList.contains("err")));
|
||||
assert.deepEqual(posts, []);
|
||||
assert.equal(await field.inputValue(), "80", "what was typed is kept");
|
||||
assert.equal(await scroll(), before, "a port out of range moved the page");
|
||||
|
||||
// taken by another program: the gateway's reason, translated
|
||||
await field.fill("3500");
|
||||
await apply.click();
|
||||
await page.waitForFunction((want) => document.querySelector("#portList .sub")?.textContent.trim() === want, w.taken);
|
||||
assert.equal(await field.inputValue(), "3500");
|
||||
assert.deepEqual(posts, [3500]);
|
||||
assert.equal(await scroll(), before, "a taken port moved the page");
|
||||
|
||||
// a free one, by Enter: moved, and the agents with it
|
||||
await field.fill("3591");
|
||||
await field.press("Enter");
|
||||
await page.waitForFunction((want) => document.querySelector("#status")?.textContent.trim() === want, w.moved);
|
||||
await page.waitForFunction((want) => document.querySelector("#portList .sub")?.textContent.trim() === want, w.now("http://127.0.0.1:3591"));
|
||||
assert.equal(await field.inputValue(), "3591");
|
||||
assert.deepEqual(posts, [3500, 3591]);
|
||||
assert.equal(await scroll(), before, "applying moved the page");
|
||||
assert.deepEqual(errors, []);
|
||||
}
|
||||
// MAGPIE_ADDR comes first: the field is off and says why
|
||||
{
|
||||
const { page, errors, posts } = await open(lang, { addrEnv: "127.0.0.1:3426", gateway: "http://127.0.0.1:3426" });
|
||||
const row = page.locator("#portList .port-row");
|
||||
assert(await row.locator("input.gateway-port").isDisabled());
|
||||
assert(await row.getByRole("button", { name: w.apply }).isDisabled());
|
||||
assert.equal((await row.locator(".sub").textContent()).trim(), w.env);
|
||||
assert.deepEqual(posts, []);
|
||||
assert.deepEqual(errors, []);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"maps"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -22,6 +23,7 @@ import (
|
||||
"github.com/tidwall/jsonc"
|
||||
"github.com/yetone/magpie/internal/appdir"
|
||||
"github.com/yetone/magpie/internal/plugin"
|
||||
"github.com/yetone/magpie/internal/settings"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
@@ -323,10 +325,19 @@ func cleanBase(u string) string {
|
||||
return u
|
||||
}
|
||||
|
||||
// gatewayURL is magpie's own address: importing it would loop.
|
||||
// gatewayURL is magpie's own address, on the port it listens on now or
|
||||
// the one Settings has: importing it would loop.
|
||||
func gatewayURL(u *url.URL) bool {
|
||||
h := u.Hostname()
|
||||
return (h == "127.0.0.1" || h == "localhost") && u.Port() == "3425"
|
||||
if h != "127.0.0.1" && h != "localhost" {
|
||||
return false
|
||||
}
|
||||
for _, a := range []string{settings.GatewayAddr(), settings.SavedAddr()} {
|
||||
if _, p, err := net.SplitHostPort(a); err == nil && u.Port() == p {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// presetAt finds the preset serving an entry's host, the region of it the
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/yetone/magpie/internal/settings"
|
||||
)
|
||||
|
||||
// magpie's own gateway is on the port Settings has (Magic_zero on
|
||||
// Discord): an app's entry there is magpie's, not one to import, and one
|
||||
// on 3425 is another program's once magpie has moved off it.
|
||||
func TestGatewayURLOnSettingsPort(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
||||
t.Setenv("MAGPIE_ADDR", "")
|
||||
is := func(u string) bool { p, _ := url.Parse(u); return gatewayURL(p) }
|
||||
if !is("http://127.0.0.1:3425/v1") || is("http://127.0.0.1:3591/v1") {
|
||||
t.Fatal("the default port")
|
||||
}
|
||||
s := settings.Load()
|
||||
s.Port = 3591
|
||||
if err := settings.Save(s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !is("http://127.0.0.1:3591/v1") || !is("http://localhost:3591") || is("http://127.0.0.1:3425/v1") || is("http://10.0.0.2:3591") {
|
||||
t.Fatal("Settings' port")
|
||||
}
|
||||
t.Setenv("MAGPIE_ADDR", "127.0.0.1:3426")
|
||||
if !is("http://127.0.0.1:3426") {
|
||||
t.Fatal("MAGPIE_ADDR's port")
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,9 @@ type Settings struct {
|
||||
// named caller keys. LANKey is retained for older Magpie versions.
|
||||
LAN bool `json:"lan,omitempty"`
|
||||
LANKey string `json:"lanKey,omitempty"`
|
||||
// Port is the gateway's port on this computer, 0 for DefaultPort.
|
||||
// MAGPIE_ADDR, where it is set, comes first (GatewayAddr).
|
||||
Port int `json:"port,omitempty"`
|
||||
// LANKeyID remembers the default named key created when sharing is enabled.
|
||||
LANKeyID string `json:"lanKeyId,omitempty"`
|
||||
// RequestArchive keeps each call the gateway serves — its headers and
|
||||
@@ -537,11 +540,11 @@ func (s Settings) Working(n int) int {
|
||||
|
||||
// KeepOwn puts back cur's settings that are this computer's own, which a
|
||||
// sync or a restored backup never brings from another: the window's size,
|
||||
// the proxy, the Dock, and what the menu bar or tray shows beside magpie's
|
||||
// the proxy, the gateway's port, the Dock, and what the menu bar or tray shows beside magpie's
|
||||
// icon (yoooo on Discord: usage turned off on a Mac came back from a
|
||||
// Windows box that shows it).
|
||||
func (s *Settings) KeepOwn(cur Settings) {
|
||||
s.Window, s.Proxy, s.Dock, s.DockWindow, s.Lightweight = cur.Window, cur.Proxy, cur.Dock, cur.DockWindow, cur.Lightweight
|
||||
s.Window, s.Proxy, s.Port, s.Dock, s.DockWindow, s.Lightweight = cur.Window, cur.Proxy, cur.Port, cur.Dock, cur.DockWindow, cur.Lightweight
|
||||
s.TrayUsages, s.TrayUsage, s.TrayUsageEvery, s.TrayNoLogos, s.TrayNoBird = cur.TrayUsages, cur.TrayUsage, cur.TrayUsageEvery, cur.TrayNoLogos, cur.TrayNoBird
|
||||
}
|
||||
|
||||
@@ -631,6 +634,41 @@ func CheckProxy(p string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DefaultPort is the gateway's port unless Settings or MAGPIE_ADDR say
|
||||
// another.
|
||||
const DefaultPort = 3425
|
||||
|
||||
// CheckPort says what is wrong with a port for the gateway: one from 1024
|
||||
// to 65535 (below that a system wants an administrator to listen on it),
|
||||
// or 0 for the default.
|
||||
func CheckPort(p int) error {
|
||||
if p != 0 && (p < 1024 || p > 65535) {
|
||||
return fmt.Errorf("the gateway's port is a number from 1024 to 65535, not %d", p)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GatewayAddr is where the gateway listens: MAGPIE_ADDR when it is set,
|
||||
// else the port Settings has, on loopback (Magic_zero on Discord: 3425 was
|
||||
// taken, and a port of one's own is easier to tell apart).
|
||||
func GatewayAddr() string {
|
||||
if a := os.Getenv("MAGPIE_ADDR"); a != "" {
|
||||
return a
|
||||
}
|
||||
return SavedAddr()
|
||||
}
|
||||
|
||||
// SavedAddr is GatewayAddr with MAGPIE_ADDR left aside: the address of the
|
||||
// magpie whose settings these are, which one the variable moves (magpie-dev,
|
||||
// a sandbox) runs beside.
|
||||
func SavedAddr() string {
|
||||
p := Load().Port
|
||||
if p == 0 || CheckPort(p) != nil {
|
||||
p = DefaultPort
|
||||
}
|
||||
return fmt.Sprintf("127.0.0.1:%d", p)
|
||||
}
|
||||
|
||||
// Save validates and writes the settings.
|
||||
func Save(s Settings) error {
|
||||
fileMu.Lock()
|
||||
@@ -680,6 +718,9 @@ func Save(s Settings) error {
|
||||
if math.IsNaN(s.BalanceAlert) || math.IsInf(s.BalanceAlert, 0) || s.BalanceAlert < 0 {
|
||||
return fmt.Errorf("a balance alert is at an amount of 0 or more (0 for off), not %v", s.BalanceAlert)
|
||||
}
|
||||
if err := CheckPort(s.Port); err != nil {
|
||||
return err
|
||||
}
|
||||
if !slices.Contains(TextSizes, s.TextSize) {
|
||||
return fmt.Errorf("text size must be one of %v percent, not %d", TextSizes, s.TextSize)
|
||||
}
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ const quotaUsage = `usage: magpie quota [<provider>…] [--json]
|
||||
when it starts again, and each key's balance, asked of the vendors now (or less than
|
||||
a minute ago). --json is for scripts and agents, each entry with lastServedAt, when it
|
||||
last answered through the gateway, and last: true on the latest; the gateway answers the same at
|
||||
GET http://127.0.0.1:3425/v1/magpie/quotas, and to another machine only while magpie is
|
||||
GET http://127.0.0.1:3425/v1/magpie/quotas (on Settings' Gateway port), and to another machine only while magpie is
|
||||
shared on the local network, with its key as the API key (Authorization: Bearer or x-api-key)
|
||||
A Codex account that holds rate-limit resets says how many; quota reset spends one,
|
||||
starting the account's current windows again (the one Codex is signed in to unless
|
||||
|
||||
Reference in new issue
Block a user