agent, gui: an agent connected by its config to an address where nothing answers is told, not shown connected (#1013, cjyrainbow: Codex Desktop's WSL environment reads Windows' ~/.codex/config.toml, whose openai_base_url and [model_providers.magpie].base_url the reporter pointed at Windows as WSL sees it, forwarded to 127.0.0.1:3425 by a portproxy of their own; after a reboot the portproxy listened no more and the Agents page still said Codex was connected) — Drift looked only at the config, which was right. Now an agent pointed at an address off loopback (this machine's Codex at an address the user kept, #816, through Agent.reach; every WSL twin under NAT, at Windows as the distro sees it, through Agent.Gateway) has that address tried from here (reach.go: GET /v1/models, any HTTP status an answer, 2 s timeout, in the background, at most every 20 s per address, never waited for), and one that doesn't answer while the gateway answers on loopback is a new drift, "unreachable", with Addr and the advice in Detail: under WSL (or on Windows) magpie with Settings › Share on local network on, or the user's netsh interface portproxy, which may need setting up again after a restart; Windows' firewall allowing the port for the WSL network; on Windows 11, networkingMode=mirrored; elsewhere, the forward to start again or gateway.URL() to point at. An answer not had yet, and a gateway down on loopback too, are no drift. The config is never changed for it, except where the address is one a WSL distro reached Windows at before (distro.Was, the last 4 kept in wsl.json as a re-probe finds Gateway changed) and the distro reaches Windows at another now: Drift's Move names that one, and Reapply moves the config there (Agent.move; Codex's codexMoveGateway rewrites openai_base_url and magpie's own provider's base_url, only where they begin with the old address). An address no distro was seen at is the user's own and is never offered a move. In the GUI the row's line says "{agent} is set up, but nothing answers at the address it reaches magpie by" in red with the advice as its tooltip, and its pill is "How to fix" (an i, not the reapply arrow), which opens the advice in a dialog (too long for the status line; it wraps and can be copied) and sets nothing — or "Use <address>" (the existing "Use {url}" string) when there is a Move, which reapplies; two new t() strings with zh, ja and de. The probe runs from Windows, not from inside WSL, so a firewall keeping WSL out isn't seen; the advice names it. TestKeptAddressNotAnswering (the reporter's case: Codex at 172.28.96.1, unreachable with the address and loopback in the detail; no drift once it answers, none with the gateway down too), TestKeptAddressProbeNeverWaitedFor, TestKeptAddressMovedWithWSL (Move from a Was address, Reapply rewrites both URLs, Check empty and no drift after; a 10.0.0.7 address is told but never moved) and TestWSLAgentAddressNotAnswering (Codex in a NAT distro told, with Share on local network, portproxy and firewall; a mirrored one never probed) fail without the Drift change and pass; TestLoopbackNotProbed, TestReachProbe (httptest: a 401 is an answer, a closed port none) and TestWSLWas pass. TestMain stubs reachProbe so no test dials out. -race -count=10 on them; go test -tags nogui ./internal/... under a temp HOME, go vet (darwin, windows), gofmt -l, linux and windows (nogui) builds pass. agent-unreachable.test.cjs (the window at 560px and the tray panel at 440px: the red line and tooltip, How to fix's dialog posting nothing, Use <address> posting the reapply, no sideways scroll) fails on main and passes on Chromium and WebKit in en, zh, ja and de; agent-connect, agent-layout, agent-field-names, aside-native, gui-de, gui-ja and i18n-keys pass on both. Not tried on a real WSL NAT setup (the Windows test box didn't answer ssh). docs/subsystems/agent-wiring.md says it.
This commit is contained in:
1 parent
c5a54bc74b
commit
588e51ea4c
13 files changed
+706
-14
No files matched your search
@@ -16,7 +16,7 @@ something else shows up as drift.
|
||||
| Fields and options | A `Field` has `Get`, `Set` and `Options`. `Set("")` returns the field to the agent's default and takes magpie's wiring out. `Quiet` fields follow another field until set (`Follows`, such as Claude Code's tiers). An `Option` carries the picker's facts: `Direct`, `Via`, `Same`, `Alias`, `Rate` and `Context`. | [`agent.go`](../../internal/agent/agent.go), [`route.go`](../../internal/agent/route.go) |
|
||||
| The list of agents | `All` lists every agent magpie knows, then the WSL agents (`wslAgents`). `Clients` lists every agent whose requests the gateway knows by name, which `usage.Agents` reads. One file per agent: `claude.go`, `codex.go`, `opencode` in `agents.go`, and so on. | [`agents.go`](../../internal/agent/agents.go) |
|
||||
| Connect | `ConnectHow` picks the model an agent starts on and reports how. `How` is one of: `kept`, `again`, `joined`, `magpie`, `same`, `alike`, `default` or `first` (#726). With no models to give, it returns `NoModelsError` (code `no_models`). | [`applied.go`](../../internal/agent/applied.go) |
|
||||
| What magpie set | `Apply` sets a field and records it in `applied.json`. `Wired` says whether magpie is in the config. `Drift` reports `unwired`, `replaced` or `bypassed`. `Reapply` sets the record again. `Keep` accepts the config as it is now. | [`applied.go`](../../internal/agent/applied.go) |
|
||||
| What magpie set | `Apply` sets a field and records it in `applied.json`. `Wired` says whether magpie is in the config. `Drift` reports `unwired`, `replaced`, `unreachable` or `bypassed`. `Reapply` sets the record again. `Keep` accepts the config as it is now. | [`applied.go`](../../internal/agent/applied.go) |
|
||||
| What came before | The stash (`stash.json`) remembers what the config said before magpie pointed it at the gateway, so Disconnect restores it rather than guessing. | [`stash.go`](../../internal/agent/stash.go) |
|
||||
| Disconnect and preview | `Disconnect` runs `Unwire`, then returns each field still on magpie to its default. `DisconnectPreview` runs the real Disconnect on a copy under a temporary home, in a subprocess (`DryRunArg`), and diffs the files. The preview is never a second account of what Disconnect does. | [`applied.go`](../../internal/agent/applied.go), [`preview.go`](../../internal/agent/preview.go) |
|
||||
| Keeping configs current | `Reseat` moves agents off a model magpie no longer serves (#200). `OnGateway` and `Rewire` write the new gateway address into every connected agent when the port changes. `Source` and `Stale` say what an unconnected agent runs on, and whether running copies still use the old list. | [`reseat.go`](../../internal/agent/reseat.go), [`rewire.go`](../../internal/agent/rewire.go), [`connectinfo.go`](../../internal/agent/connectinfo.go) |
|
||||
@@ -28,7 +28,7 @@ something else shows up as drift.
|
||||
1. **Connect.** The Agents page's switch calls `ConnectHow` (`POST /api/agents/{action}/{id}` in [`internal/gui/api.go`](../../internal/gui/api.go)). It stashes the current values (`<id>.connect.was`). It brings back the models used before a switch-off (`reconnect`), or calls `Join` where the agent keeps its own model (Codex signed in with ChatGPT). Otherwise it picks a model by the order `ConnectHow` documents and sets it through `Apply`.
|
||||
2. **Pick.** `Pick` sets a field from the model picker. Picking one of magpie's models on an agent that isn't connected connects it first.
|
||||
3. **Notice.** After a change the page shows `Notice` when the agent reads its config only at start-up and needs a restart.
|
||||
4. **Drift.** As the Agents page is drawn, `Drift` compares the config with `applied.json`. It runs `Check` first, then each field. When the config is right, it checks whether the agent's latest use (`LastUsed`, `Reached`) reached the gateway.
|
||||
4. **Drift.** As the Agents page is drawn, `Drift` compares the config with `applied.json`. It runs `Check` first, then each field. When the config is right and points at an address off loopback (a WSL twin under NAT, or this machine's Codex at an address the user kept, #816), it tries that address ([`reach.go`](../../internal/agent/reach.go)): in the background, at most every 20 s, never waited for. When nothing answers there while the gateway answers on loopback, the drift is `unreachable` (#1013), with what has to listen there: Share on local network, the user's portproxy, Windows' firewall. Then it checks whether the agent's latest use (`LastUsed`, `Reached`) reached the gateway.
|
||||
5. **Disconnect.** `Disconnect` puts back what the stash kept and forgets the record.
|
||||
|
||||
## Constraints and failure behavior
|
||||
@@ -37,6 +37,7 @@ something else shows up as drift.
|
||||
- Codex signed in to ChatGPT stays beside its sign-in (`openai_base_url`) unless the user picks magpie API. magpie becomes its provider only while the Codex app holds the active account, which it sends nothing for: `/wham/usage` says it isn't allowed, and it has no credits and isn't a workspace still within its overage (`codexHeld`, read by `provider.CodexUsedUp`). Where the app goes by what usage can't show (an experiment's gate, a reserve, a Team plan's overage under the reserve experiment), it counts as held: a wrong move costs ChatGPT extras, a missed one every turn. A saved reading kept through a failed read stops saying held once its used-up window has started again; a hold with no window used up stays till a fresh read. The app then loses its ChatGPT state (durable threads, remote control), so a window at 100% alone, which credits get past, doesn't move it (`codex.out` marks the move, and Sync undoes it once the account has room).
|
||||
- Moving a field from one of magpie's models to another (the agent's own picker) is not drift. Moving it off magpie is.
|
||||
- A value an agent spells as its own provider's model is never taken for one of magpie's, even when magpie has a provider of the same name (`Spelled`, #835).
|
||||
- `unreachable` never sets anything: the config is right, and the page's pill only says what has to listen. The exception is an address a WSL distro reached Windows at before (`distro.Was`, kept in `wsl.json`) while it reaches Windows at another now: the pill offers that one, and `Reapply` moves the config there (`Agent.move`, Codex's `codexMoveGateway`). An address no distro was seen at is the user's own and is never moved. An unknown answer (no probe yet) and a gateway down on loopback too are not drift. Tests stub `reachProbe` in `TestMain`.
|
||||
- Listing never starts a stopped WSL distro. A stopped distro's agent shows what was last seen there (`asleep`, `wsl.json`); setting one of its fields writes its files, which starts the distro.
|
||||
- Codex's sign-in (`login`) is ChatGPT (`""`, the default), Always ChatGPT (`chatgpt`) or magpie API (`api`). With ChatGPT, magpie's models join Codex's own beside the sign-in (`openai_base_url`), and while the Codex app holds the ChatGPT account Codex is signed in to (`codexUsedUp`, `provider.CodexUsedUp`: OpenAI no longer allows the account and it has no credits left, a workspace still within its overage excepted), magpie is made Codex's provider (`codex.out`): the Codex app sends nothing at all on an account it holds, a magpie model's turn included (#540), and as a provider of its own magpie is past that. A window at 100% with credits left doesn't count, and Codex stays signed in. Sync puts it back beside the sign-in once the account has room. Always ChatGPT never does that: Codex stays in its ChatGPT state, and the Codex app may send nothing till the account has room, while Codex CLI's turns still reach magpie by the base URL, and its routing and other accounts serve them.
|
||||
- An agent's config is written in the form the installed version reads, taken from what that version declares, not the latest one. VSCodium has no Copilot Chat of its own, and the Marketplace's last one (0.48.1) has no Custom Endpoint provider, only OpenAI Compatible (`customoai`, #1014). magpie reads the provider list in the package.json of the `github.copilot-chat` installed under `~/.vscode-oss/extensions` (`vscodeChatOf`). It writes its group for `customendpoint` when that is declared, for `customoai` when only that is, with each model's window as `maxInputTokens` + `maxOutputTokens` and the token as `x-api-key` (that version drops an Authorization in `requestHeaders`). A missing or unreadable extension keeps `customendpoint`. Sync rewrites a group written for the other provider, and Check reports it, as it does a Copilot Chat declaring neither. That Copilot Chat registers its providers only while signed in to GitHub with a personal Copilot plan, which the Notice says.
|
||||
@@ -45,7 +46,7 @@ something else shows up as drift.
|
||||
## Verification
|
||||
|
||||
```sh
|
||||
go test -tags nogui ./internal/agent -run 'TestConnect|TestDisconnect|Drift|TestDryRunKnowsTheAskersProviders|TestReseat|TestPort|TestWSL'
|
||||
go test -tags nogui ./internal/agent -run 'TestConnect|TestDisconnect|Drift|TestKeptAddress|TestWSLAgentAddress|TestReachProbe|TestDryRunKnowsTheAskersProviders|TestReseat|TestPort|TestWSL'
|
||||
go test -tags nogui ./internal/agent
|
||||
node --test internal/gui/tests/agent-connect.test.cjs internal/gui/tests/agent-disconnect-preview.test.cjs
|
||||
```
|
||||
@@ -208,6 +208,13 @@ type Agent struct {
|
||||
// detect, when set, says whether the agent is here in place of looking
|
||||
// for its files and binary: a distro's, probed once.
|
||||
detect func() bool
|
||||
// reach, when set, is the gateway's address as the agent's config has
|
||||
// it where that is kept apart from Gateway (this machine's Codex at an
|
||||
// address of the user's, #816): Drift tries it (see reach.go).
|
||||
reach func() string
|
||||
// move, when set, points the agent's config at to where it names the
|
||||
// gateway at from: an address of WSL's that changed (#1013).
|
||||
move func(from, to string) error
|
||||
}
|
||||
|
||||
// Running reports whether a process whose command line matches any pattern
|
||||
|
||||
@@ -113,12 +113,18 @@ type Drift struct {
|
||||
// "replaced" a magpie model magpie set was replaced by the agent's own;
|
||||
// "bypassed" the config is right, yet the agent was used since and
|
||||
// nothing of it reached the gateway — it runs on an old
|
||||
// config, or something outside the file overrides it.
|
||||
// config, or something outside the file overrides it;
|
||||
// "unreachable" the config is right, but the address off loopback
|
||||
// it names the gateway at doesn't answer (#1013).
|
||||
Kind string `json:"kind"`
|
||||
Field string `json:"field"` // the field it shows on
|
||||
Now string `json:"now,omitempty"` // what that field says now
|
||||
Want string `json:"want"` // what setting it again sets
|
||||
Detail string `json:"detail"` // what exactly is off, for a tooltip
|
||||
// Addr is where an unreachable agent is pointed; Move, when set, the
|
||||
// address WSL reaches Windows at now, which Reapply points it at.
|
||||
Addr string `json:"addr,omitempty"`
|
||||
Move string `json:"move,omitempty"`
|
||||
}
|
||||
|
||||
// started is when this process — and the gateway in it — came up: before
|
||||
@@ -168,6 +174,11 @@ func (a *Agent) Drift() *Drift {
|
||||
return &Drift{Kind: "replaced", Field: f.Key, Now: vals[f.Key], Want: want,
|
||||
Detail: a.Name + "'s config was changed outside magpie: " + f.Label + " is " + orDefault(vals[f.Key]) + ", not " + want + " as magpie set it"}
|
||||
}
|
||||
if onMagpie || joined {
|
||||
if d := a.unreachable(on, vals[on.Key]); d != nil {
|
||||
return d
|
||||
}
|
||||
}
|
||||
if a.Reached != nil && onMagpie {
|
||||
if at, to, refused := a.Reached(rec.At); !at.IsZero() {
|
||||
switch {
|
||||
@@ -306,6 +317,9 @@ func (a *Agent) Reapply() error {
|
||||
return a.Native.Connect()
|
||||
}
|
||||
d := a.Drift()
|
||||
if d != nil && d.Kind == "unreachable" && d.Move != "" && a.move != nil {
|
||||
return a.move(d.Addr, d.Move)
|
||||
}
|
||||
if d != nil && d.Kind == "replaced" {
|
||||
rec := appliedOf(a.ID)
|
||||
// the model first: the others (an effort) are checked against it
|
||||
|
||||
+24
-1
@@ -654,7 +654,7 @@ func codexIn(at place) *Agent {
|
||||
return settle()
|
||||
}
|
||||
|
||||
return atomic(&Agent{
|
||||
a := atomic(&Agent{
|
||||
ID: "codex", Name: "Codex", Icon: "codex-color", Bin: "codex", Dir: dir, Path: path,
|
||||
UA: []string{"codex"},
|
||||
// Codex as it was before magpie: its default puts it back as
|
||||
@@ -1010,6 +1010,13 @@ func codexIn(at place) *Agent {
|
||||
},
|
||||
},
|
||||
}, path, catalogPath)
|
||||
// this machine's Codex: the address its config keeps is tried (Drift),
|
||||
// and moved to WSL's new one when that changed (#1013)
|
||||
if at.spell == nil {
|
||||
a.reach = at.base
|
||||
a.move = func(from, to string) error { return codexMoveGateway(path, from, to) }
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// ccSwitchProvider matches the ids of the provider tables CC Switch writes
|
||||
@@ -1183,6 +1190,22 @@ func codexKeptGateway(path string) string {
|
||||
return cmp.Or(kept(base, gateway.CodexPath), table, gateway.URL())
|
||||
}
|
||||
|
||||
// codexMoveGateway points Codex's config at to where it names magpie's
|
||||
// gateway at from: openai_base_url and [model_providers.magpie]'s
|
||||
// base_url, each only when it is at from; anything else is the user's.
|
||||
func codexMoveGateway(path, from, to string) error {
|
||||
from, to = strings.TrimSuffix(from, "/"), strings.TrimSuffix(to, "/")
|
||||
if u, _ := edit.GetTOMLTop(path, "openai_base_url"); strings.HasPrefix(u, from+"/") {
|
||||
if err := edit.SetTOMLTop(path, edit.KV{Path: "openai_base_url", Value: to + strings.TrimPrefix(u, from)}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if t, _ := edit.GetTOMLTable(path, "model_providers."+magpieID); ourKey(t["experimental_bearer_token"]) && strings.HasPrefix(t["base_url"], from+"/") {
|
||||
return edit.SetTOMLKey(path, "model_providers."+magpieID, "base_url", to+strings.TrimPrefix(t["base_url"], from))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isCodexGatewayOn reports whether an openai_base_url is magpie's gateway
|
||||
// reached at host, on whichever port it listened on then.
|
||||
func isCodexGatewayOn(u, host string) bool {
|
||||
|
||||
@@ -38,5 +38,8 @@ func TestMain(m *testing.M) {
|
||||
// applied. The Aside tests stand in for it themselves.
|
||||
asideSet = func(string, string) error { return errors.New("aside: no Aside in a test") }
|
||||
asideRead = func() (map[string]json.RawMessage, error) { return nil, errors.New("aside: no Aside in a test") }
|
||||
// an address an agent is pointed at is tried over the network (Drift,
|
||||
// #1013); never from a test, whose reach tests stand in for it
|
||||
reachProbe = func(string) bool { return true }
|
||||
os.Exit(testenv.Run(m))
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/yetone/magpie/internal/gateway"
|
||||
)
|
||||
|
||||
// An agent pointed at magpie by an address other than this machine's
|
||||
// loopback — this machine's Codex at Windows as WSL sees it or a forward of
|
||||
// the user's (#816), an agent in a WSL distro under NAT — is connected by
|
||||
// its config alone; whether anything answers there is another matter
|
||||
// (#1013: a portproxy gone after a reboot, the Agents page still said
|
||||
// connected). Drift asks: each such address is tried from here, in the
|
||||
// background, and one that doesn't answer while the gateway does on
|
||||
// loopback is told (Drift kind "unreachable"), with what listens there.
|
||||
|
||||
// reachAge is how long an answer is taken as it is: Drift is asked every
|
||||
// few seconds while the window is up, a probe at most this often.
|
||||
var reachAge = 20 * time.Second
|
||||
|
||||
// reachProbe says whether anything HTTP answers at base: any status will
|
||||
// do (a gateway shared on the network answers 401 to no key), a refused or
|
||||
// timed-out connection doesn't. A var for tests.
|
||||
var reachProbe = realReachProbe
|
||||
|
||||
func realReachProbe(base string) bool {
|
||||
c := &http.Client{Timeout: 2 * time.Second}
|
||||
res, err := c.Get(strings.TrimSuffix(base, "/") + "/v1/models")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
res.Body.Close()
|
||||
return true
|
||||
}
|
||||
|
||||
var reach struct {
|
||||
sync.Mutex
|
||||
m map[string]reachAnswer
|
||||
}
|
||||
|
||||
type reachAnswer struct {
|
||||
at time.Time // when the last probe finished; zero: none yet
|
||||
ok, busy bool
|
||||
}
|
||||
|
||||
// reachable is what the last probe of base said, and whether there is one
|
||||
// (known); a probe older than reachAge, or none, is started in the
|
||||
// background and never waited for, so Drift stays quick.
|
||||
func reachable(base string) (ok, known bool) {
|
||||
reach.Lock()
|
||||
defer reach.Unlock()
|
||||
if reach.m == nil {
|
||||
reach.m = map[string]reachAnswer{}
|
||||
}
|
||||
a := reach.m[base]
|
||||
if !a.busy && time.Since(a.at) > reachAge {
|
||||
a.busy = true
|
||||
reach.m[base] = a
|
||||
probe := reachProbe
|
||||
go func() {
|
||||
ok := probe(base)
|
||||
reach.Lock()
|
||||
reach.m[base] = reachAnswer{at: time.Now(), ok: ok}
|
||||
reach.Unlock()
|
||||
}()
|
||||
}
|
||||
return a.ok, !a.at.IsZero()
|
||||
}
|
||||
|
||||
// offLoopback: base is an http address on a host other than this
|
||||
// machine's loopback and the gateway's own.
|
||||
func offLoopback(base string) bool {
|
||||
u, err := url.Parse(base)
|
||||
if err != nil || u.Scheme != "http" || u.Hostname() == "" {
|
||||
return false
|
||||
}
|
||||
h := u.Hostname()
|
||||
if h == "localhost" || sameHost(base, gateway.URL()) {
|
||||
return false
|
||||
}
|
||||
ip := net.ParseIP(h)
|
||||
return ip == nil || !ip.IsLoopback()
|
||||
}
|
||||
|
||||
// pointedAt is the gateway's address in the agent's config: where it is
|
||||
// kept apart from gateway.URL (this machine's Codex), a WSL distro's way to
|
||||
// it, else "".
|
||||
func (a *Agent) pointedAt() string {
|
||||
switch {
|
||||
case a.reach != nil:
|
||||
return a.reach()
|
||||
case a.Gateway != nil:
|
||||
return a.Gateway()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// unreachable is the drift of an agent pointed at an address off loopback
|
||||
// that doesn't answer from here while the gateway answers on loopback; nil
|
||||
// when it answers, isn't asked yet, or the gateway itself is down (then
|
||||
// nothing would, and that is no fault of the address).
|
||||
func (a *Agent) unreachable(on Field, now string) *Drift {
|
||||
addr := a.pointedAt()
|
||||
if !offLoopback(addr) {
|
||||
return nil
|
||||
}
|
||||
// both asked at once, so one round of probes settles it
|
||||
ok, known := reachable(addr)
|
||||
up, upKnown := reachable(gateway.URL())
|
||||
if ok || !known || !up || !upKnown {
|
||||
return nil
|
||||
}
|
||||
d := &Drift{Kind: "unreachable", Field: on.Key, Now: now, Want: now, Addr: addr}
|
||||
host := strings.TrimPrefix(addr, "http://")
|
||||
d.Detail = a.Name + " is pointed at magpie at " + addr + ", where nothing answers from this computer, though magpie answers at " + gateway.URL() + ". "
|
||||
if a.WSL != "" || runtime.GOOS == "windows" {
|
||||
d.Detail += "Under WSL's NAT networking, Windows as WSL sees it reaches magpie only while something listens on " + host +
|
||||
": magpie itself with Settings › Share on local network on (it then listens on every address, and asks other computers for a gateway key), or a forward of your own (netsh interface portproxy, which may need setting up again after Windows restarts). " +
|
||||
"Once it answers here and WSL still can't connect, Windows' firewall may be keeping WSL out: allow the port " + gateway.Port() + " for the WSL network. " +
|
||||
"On Windows 11, networkingMode=mirrored under [wsl2] in %UserProfile%\\.wslconfig lets WSL reach magpie at 127.0.0.1 instead."
|
||||
} else {
|
||||
d.Detail += "Whatever forwarded " + host + " to magpie isn't listening now; start it again, or point " + a.Name + " at " + gateway.URL() + "."
|
||||
}
|
||||
if to, distro := wslMovedFrom(addr); to != "" {
|
||||
d.Move = to
|
||||
d.Detail += " WSL " + distro + " reaches Windows at " + to + " now, not " + addr + " as before: Use " + to + " points " + a.Name + " there."
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// wslMovedFrom is the address a WSL distro reaches Windows at now, when
|
||||
// base is on one it reached Windows at before (its networking's address
|
||||
// changed since), and that distro's name; "" otherwise. A host no distro
|
||||
// was seen at is the user's own, and is never offered to be moved.
|
||||
func wslMovedFrom(base string) (to, name string) {
|
||||
u, err := url.Parse(base)
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
h, port := u.Hostname(), u.Port()
|
||||
wsl.Lock()
|
||||
defer wsl.Unlock()
|
||||
for n, d := range wsl.seen {
|
||||
if d == nil || d.Gateway == "" || d.Gateway == h {
|
||||
continue
|
||||
}
|
||||
for _, w := range d.Was {
|
||||
if w == h {
|
||||
if port == "" {
|
||||
port = gateway.Port()
|
||||
}
|
||||
return "http://" + net.JoinHostPort(d.Gateway, port), n
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/yetone/magpie/internal/edit"
|
||||
"github.com/yetone/magpie/internal/gateway"
|
||||
"github.com/yetone/magpie/internal/provider"
|
||||
)
|
||||
|
||||
// fakeReach answers reachProbe from answers (a base missing is down), and
|
||||
// starts with no probe made.
|
||||
func fakeReach(t *testing.T, answers map[string]bool) *sync.Mutex {
|
||||
t.Helper()
|
||||
var mu sync.Mutex
|
||||
was, age := reachProbe, reachAge
|
||||
reachProbe = func(base string) bool { mu.Lock(); defer mu.Unlock(); return answers[base] }
|
||||
reachAge = time.Hour
|
||||
reach.Lock()
|
||||
reach.m = nil
|
||||
reach.Unlock()
|
||||
t.Cleanup(func() {
|
||||
reachProbe, reachAge = was, age
|
||||
reach.Lock()
|
||||
reach.m = nil
|
||||
reach.Unlock()
|
||||
})
|
||||
return &mu
|
||||
}
|
||||
|
||||
// reachForget drops every answer, so the next Drift asks again.
|
||||
func reachForget() {
|
||||
reach.Lock()
|
||||
reach.m = nil
|
||||
reach.Unlock()
|
||||
}
|
||||
|
||||
// driftSettled is the agent's drift once the probes it starts have
|
||||
// answered: the first ask only starts them.
|
||||
func driftSettled(t *testing.T, a *Agent) *Drift {
|
||||
t.Helper()
|
||||
a.Drift()
|
||||
for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); time.Sleep(5 * time.Millisecond) {
|
||||
reach.Lock()
|
||||
busy := false
|
||||
for _, r := range reach.m {
|
||||
busy = busy || r.busy
|
||||
}
|
||||
reach.Unlock()
|
||||
if !busy {
|
||||
return a.Drift()
|
||||
}
|
||||
}
|
||||
t.Fatal("the probes never answered")
|
||||
return nil
|
||||
}
|
||||
|
||||
// codexAt is this machine's Codex connected on fake/m1, its config then
|
||||
// pointed at magpie by base as the user wrote it (#816).
|
||||
func codexAt(t *testing.T, base string) (string, func() string) {
|
||||
t.Helper()
|
||||
home, read := codexHome(t, `{"OPENAI_API_KEY":"sk-x"}`, "")
|
||||
if err := provider.Save(provider.Provider{ID: "fake", Name: "Fake", Key: "k", Chat: "http://127.0.0.1:1/v1", Models: []string{"m1"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cx := codex(home)
|
||||
if err := cx.Fields[0].Set("fake/m1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := edit.SetTOMLTop(cx.Path, edit.KV{Path: "openai_base_url", Value: base + gateway.CodexPath}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := edit.SetTOMLKey(cx.Path, "model_providers."+magpieID, "base_url", base+"/v1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c := codex(home).Check(); c != "" {
|
||||
t.Fatal(c)
|
||||
}
|
||||
return home, read
|
||||
}
|
||||
|
||||
// #1013 (cjyrainbow): Codex on Windows, its WSL agent reading Windows'
|
||||
// config, pointed at magpie by Windows' address as WSL sees it and a
|
||||
// portproxy of the user's to 127.0.0.1. After a reboot the portproxy
|
||||
// listened no more, and the Agents page still said connected: the config
|
||||
// was right. Now the address is tried, and one where nothing answers
|
||||
// while magpie does on loopback is told, with what has to listen there.
|
||||
func TestKeptAddressNotAnswering(t *testing.T) {
|
||||
kept := "http://172.28.96.1:" + gateway.Port()
|
||||
answers := map[string]bool{gateway.URL(): true}
|
||||
mu := fakeReach(t, answers)
|
||||
home, _ := codexAt(t, kept)
|
||||
|
||||
d := driftSettled(t, codex(home))
|
||||
if d == nil || d.Kind != "unreachable" || d.Addr != kept || d.Move != "" {
|
||||
t.Fatalf("drift %+v", d)
|
||||
}
|
||||
if !strings.Contains(d.Detail, kept) || !strings.Contains(d.Detail, gateway.URL()) {
|
||||
t.Fatalf("detail: %s", d.Detail)
|
||||
}
|
||||
|
||||
// the forward is back: connected again
|
||||
mu.Lock()
|
||||
answers[kept] = true
|
||||
mu.Unlock()
|
||||
reachForget()
|
||||
if d := driftSettled(t, codex(home)); d != nil {
|
||||
t.Fatalf("answering, still %+v", d)
|
||||
}
|
||||
|
||||
// magpie's gateway itself down: nothing answers anywhere, which is no
|
||||
// fault of the address
|
||||
mu.Lock()
|
||||
answers[kept], answers[gateway.URL()] = false, false
|
||||
mu.Unlock()
|
||||
reachForget()
|
||||
if d := driftSettled(t, codex(home)); d != nil {
|
||||
t.Fatalf("gateway down, told %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// The first ask doesn't wait for the network: Drift answers at once, and
|
||||
// the probe's answer shows at a later ask.
|
||||
func TestKeptAddressProbeNeverWaitedFor(t *testing.T) {
|
||||
kept := "http://172.28.96.1:" + gateway.Port()
|
||||
fakeReach(t, map[string]bool{gateway.URL(): true})
|
||||
home, _ := codexAt(t, kept)
|
||||
release := make(chan struct{})
|
||||
slow := reachProbe
|
||||
reachProbe = func(base string) bool { <-release; return slow(base) }
|
||||
start := time.Now()
|
||||
if d := codex(home).Drift(); d != nil {
|
||||
t.Fatalf("drift before any answer: %+v", d)
|
||||
}
|
||||
if time.Since(start) > time.Second {
|
||||
t.Fatal("Drift waited for the probe")
|
||||
}
|
||||
close(release)
|
||||
if d := driftSettled(t, codex(home)); d == nil || d.Kind != "unreachable" {
|
||||
t.Fatalf("drift %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// Under WSL's NAT the address WSL reaches Windows at can change; a config
|
||||
// still on the one a distro reached Windows at before is offered the one
|
||||
// now, and Reconnect moves both of Codex's URLs there. An address no
|
||||
// distro was ever seen at is the user's own and is never offered a move.
|
||||
func TestKeptAddressMovedWithWSL(t *testing.T) {
|
||||
old, now := "http://172.28.96.1:"+gateway.Port(), "http://172.29.0.1:"+gateway.Port()
|
||||
fakeReach(t, map[string]bool{gateway.URL(): true, now: true})
|
||||
wsl.Lock()
|
||||
seen := wsl.seen
|
||||
wsl.seen = map[string]*distro{"Ubuntu": {Name: "Ubuntu", Gateway: "172.29.0.1", Was: []string{"172.28.96.1"}}}
|
||||
wsl.Unlock()
|
||||
t.Cleanup(func() { wsl.Lock(); wsl.seen = seen; wsl.Unlock() })
|
||||
|
||||
home, read := codexAt(t, old)
|
||||
cx := codex(home)
|
||||
d := driftSettled(t, cx)
|
||||
if d == nil || d.Kind != "unreachable" || d.Move != now || !strings.Contains(d.Detail, "Use "+now) {
|
||||
t.Fatalf("drift %+v", d)
|
||||
}
|
||||
if err := cx.Reapply(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := read()
|
||||
if !strings.Contains(cfg, `openai_base_url = "`+now+gateway.CodexPath+`"`) || !strings.Contains(cfg, `base_url = "`+now+`/v1"`) || strings.Contains(cfg, "172.28.96.1") {
|
||||
t.Fatalf("not moved:\n%s", cfg)
|
||||
}
|
||||
if c := codex(home).Check(); c != "" {
|
||||
t.Fatal(c)
|
||||
}
|
||||
if d := driftSettled(t, codex(home)); d != nil {
|
||||
t.Fatalf("moved, still %+v", d)
|
||||
}
|
||||
|
||||
// the user's own forward on another host: told, never moved
|
||||
reachForget()
|
||||
home, read = codexAt(t, "http://10.0.0.7:"+gateway.Port())
|
||||
cx = codex(home)
|
||||
if d := driftSettled(t, cx); d == nil || d.Kind != "unreachable" || d.Move != "" {
|
||||
t.Fatalf("drift %+v", d)
|
||||
}
|
||||
if err := cx.Reapply(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg := read(); !strings.Contains(cfg, "10.0.0.7") {
|
||||
t.Fatalf("the user's address was changed:\n%s", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
// Codex on loopback is never probed: there is nothing apart from the
|
||||
// gateway to try.
|
||||
func TestLoopbackNotProbed(t *testing.T) {
|
||||
var asked []string
|
||||
var mu sync.Mutex
|
||||
fakeReach(t, nil)
|
||||
reachProbe = func(base string) bool { mu.Lock(); asked = append(asked, base); mu.Unlock(); return false }
|
||||
home, _ := codexHome(t, `{"OPENAI_API_KEY":"sk-x"}`, "")
|
||||
if err := provider.Save(provider.Provider{ID: "fake", Name: "Fake", Key: "k", Chat: "http://127.0.0.1:1/v1", Models: []string{"m1"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cx := codex(home)
|
||||
if err := cx.Fields[0].Set("fake/m1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d := driftSettled(t, cx); d != nil {
|
||||
t.Fatalf("drift %+v", d)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(asked) != 0 {
|
||||
t.Fatalf("probed %v", asked)
|
||||
}
|
||||
}
|
||||
|
||||
// The probe takes any HTTP answer for one (a gateway shared on the network
|
||||
// answers 401 without a key) and a refused connection for none.
|
||||
func TestReachProbe(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/models" {
|
||||
t.Errorf("asked %s", r.URL.Path)
|
||||
}
|
||||
http.Error(w, "no key", http.StatusUnauthorized)
|
||||
}))
|
||||
if !realReachProbe(srv.URL) {
|
||||
t.Fatal("a 401 taken for nothing listening")
|
||||
}
|
||||
srv.Close()
|
||||
if realReachProbe(srv.URL) {
|
||||
t.Fatal("a closed port taken for an answer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWSLWas(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
before []string
|
||||
was, now string
|
||||
want string
|
||||
}{
|
||||
{nil, "", "172.20.0.1", ""},
|
||||
{nil, "172.20.0.1", "172.20.0.1", ""},
|
||||
{nil, "172.20.0.1", "172.21.0.1", "172.20.0.1"},
|
||||
{[]string{"172.21.0.1"}, "172.20.0.1", "172.21.0.1", "172.20.0.1"},
|
||||
{[]string{"a", "b", "c", "d"}, "e", "f", "b c d e"},
|
||||
{[]string{"a"}, "b", "a", "b"},
|
||||
} {
|
||||
if got := strings.Join(wslWas(c.before, c.was, c.now), " "); got != c.want {
|
||||
t.Errorf("wslWas(%v, %q, %q) = %q, want %q", c.before, c.was, c.now, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The same for every agent in a WSL distro under NAT, pointed at Windows as
|
||||
// WSL sees it: answering only while the gateway listens beyond loopback, it
|
||||
// is told when nothing does; one in mirrored networking is on 127.0.0.1
|
||||
// and never probed.
|
||||
func TestWSLAgentAddressNotAnswering(t *testing.T) {
|
||||
win := "http://172.20.0.1:" + gateway.Port()
|
||||
fakeReach(t, map[string]bool{gateway.URL(): true})
|
||||
for _, mirrored := range []bool{false, true} {
|
||||
reachForget()
|
||||
home, _ := codexHome(t, `{"OPENAI_API_KEY":"sk-x"}`, "model = \"gpt-5.5\"\n")
|
||||
a := wslCodex(fakeDistro(home, mirrored))
|
||||
if err := a.Fields[0].Set("fake/m1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := driftSettled(t, a)
|
||||
if mirrored {
|
||||
if d != nil {
|
||||
t.Fatalf("mirrored: %+v", d)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if d == nil || d.Kind != "unreachable" || d.Addr != win || !strings.Contains(d.Detail, "Share on local network") || !strings.Contains(d.Detail, "portproxy") || !strings.Contains(d.Detail, "firewall") {
|
||||
t.Fatalf("drift %+v", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
-7
@@ -119,13 +119,16 @@ func (p place) key(k string) string {
|
||||
|
||||
// distro is one WSL distro, as probed.
|
||||
type distro struct {
|
||||
Name string `json:"name"`
|
||||
Home string `json:"home"` // $HOME inside it, e.g. /home/me
|
||||
Root string `json:"root"` // where magpie opens its / from, e.g. \\wsl.localhost\Ubuntu
|
||||
Has map[string]bool `json:"has"` // "dir:.codex", "bin:pi": what the probe found
|
||||
Probe int `json:"probe,omitempty"` // the wslProbeVersion that found it
|
||||
Gateway string `json:"gateway,omitempty"` // the Windows host as the distro reaches it, when not mirrored
|
||||
Values map[string]string `json:"values,omitempty"` // its agents' fields as last read (wslKind.memo), shown while it is stopped
|
||||
Name string `json:"name"`
|
||||
Home string `json:"home"` // $HOME inside it, e.g. /home/me
|
||||
Root string `json:"root"` // where magpie opens its / from, e.g. \\wsl.localhost\Ubuntu
|
||||
Has map[string]bool `json:"has"` // "dir:.codex", "bin:pi": what the probe found
|
||||
Probe int `json:"probe,omitempty"` // the wslProbeVersion that found it
|
||||
Gateway string `json:"gateway,omitempty"` // the Windows host as the distro reaches it, when not mirrored
|
||||
// Was are the addresses it reached Windows at before, newest last: an
|
||||
// agent's config still on one is offered the one now (#1013)
|
||||
Was []string `json:"was,omitempty"`
|
||||
Values map[string]string `json:"values,omitempty"` // its agents' fields as last read (wslKind.memo), shown while it is stopped
|
||||
// Net is WSL's networking mode as the distro's wslinfo said at the
|
||||
// last probe ("mirrored", "nat", "virtioproxy", "none"); "" when it
|
||||
// couldn't say (a WSL without wslinfo), and .wslconfig is read instead
|
||||
@@ -702,6 +705,7 @@ func wslDistros() []distro {
|
||||
if wslFound(*d) {
|
||||
if old := wsl.seen[n]; old != nil {
|
||||
d.Values = old.Values
|
||||
d.Was = wslWas(old.Was, old.Gateway, d.Gateway)
|
||||
}
|
||||
wsl.seen[n] = d
|
||||
} else {
|
||||
@@ -732,6 +736,23 @@ func wslDistros() []distro {
|
||||
return out
|
||||
}
|
||||
|
||||
// wslWas is the addresses a distro reached Windows at before, with the
|
||||
// last one (was) added when it reaches Windows elsewhere now: the newest
|
||||
// few, now's left out.
|
||||
func wslWas(before []string, was, now string) []string {
|
||||
out := slices.DeleteFunc(slices.Clone(before), func(h string) bool { return h == was || h == now })
|
||||
if was != "" && was != now {
|
||||
out = append(out, was)
|
||||
}
|
||||
if len(out) > 4 {
|
||||
out = out[len(out)-4:]
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// wslForgetOldBins drops the commands a probe before wslProbeVersion
|
||||
// found: it took a Windows command on the PATH WSL inherits (Windows npm's
|
||||
// pi under /mnt/c, #406) for one in the distro. A distro then found by
|
||||
|
||||
@@ -396,6 +396,8 @@ body.panel #agents > .row .ag-handle::before { left: -10px; }
|
||||
.ag-fix:focus-visible { outline: 1.5px solid var(--drift); outline-offset: 1px; }
|
||||
.ag-fix.busy { pointer-events: none; opacity: .7; }
|
||||
.ag-fix.busy svg { animation: spin .8s linear infinite; }
|
||||
/* what has to listen at an address no agent reaches magpie by (#1013): long addresses wrap, and it can be copied */
|
||||
.drift-why { overflow-wrap: anywhere; user-select: text; -webkit-user-select: text; }
|
||||
/* the agent's CLI on its name's line (#202): its version faint, and a quiet
|
||||
pill that updates it when a newer one is out — the row keeps its height */
|
||||
body.window .row.agent .who { display: flex; align-items: center; gap: 7px; container-type: inline-size; }
|
||||
|
||||
@@ -754,24 +754,57 @@ function profileDetail(p, footed) {
|
||||
// driftFix is the one thing a drifted agent shows: an amber pill after its
|
||||
// name that sets magpie's settings again. What is off is its tooltip; taking
|
||||
// the config as it is now is in the row's menu.
|
||||
// An agent whose config is right but whose address doesn't answer (#1013)
|
||||
// has nothing to set again: its pill says what has to listen there, unless
|
||||
// WSL now reaches Windows at another address, which it moves the config to.
|
||||
function driftFix(a, label = "Apply again") {
|
||||
const d = a.drift, f = a.fields.find((x) => x.key === d.field);
|
||||
const want = (f && optionFor(f, d.want)?.label) || d.want;
|
||||
const fix = el("button", "ag-fix");
|
||||
fix.type = "button";
|
||||
fix.title = `${t(DRIFT_WHY[d.kind] || DRIFT_WHY.unwired, { agent: a.name, model: want })}\n${d.detail}`;
|
||||
const deaf = d.kind === "unreachable";
|
||||
if (deaf && d.move) label = t("Use {url}", { url: d.move.replace(/^https?:\/\//, "") });
|
||||
else if (deaf) label = "How to fix";
|
||||
fix.setAttribute("aria-label", t(label));
|
||||
fix.append(svg(REAPPLY, 11, 1.8), el("span", "", t(label)));
|
||||
fix.onclick = (e) => { e.stopPropagation(); reapplyAgent(a, fix); };
|
||||
fix.append(svg(deaf && !d.move ? INFO_I : REAPPLY, 11, 1.8), el("span", "", t(label)));
|
||||
fix.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
if (deaf && !d.move) explainDrift(a);
|
||||
else reapplyAgent(a, fix);
|
||||
};
|
||||
return fix;
|
||||
}
|
||||
|
||||
// explainDrift: what has to listen at an address an agent can't reach
|
||||
// magpie by — too long for the status line, so a dialog of its own.
|
||||
function explainDrift(a) {
|
||||
const ed = el("div", "editor");
|
||||
const head = el("div", "ehead");
|
||||
head.append(el("b", "", t(DRIFT_WHY.unreachable, { agent: a.name })));
|
||||
ed.append(head);
|
||||
ed.append(el("p", "lib-confirm drift-why", a.drift.detail));
|
||||
const bar = el("div", "bar");
|
||||
const ok = el("button", "text primary", t("OK"));
|
||||
ok.onclick = (e) => { e.stopPropagation(); closeConfirmAsk(); };
|
||||
bar.append(el("span", "grow"), ok);
|
||||
ed.append(bar);
|
||||
confirmAsk = ed;
|
||||
openModal(ed);
|
||||
$("#modal").classList.add("lib");
|
||||
ok.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
const DRIFT_WHY = {
|
||||
unwired: "{agent} no longer goes through magpie — its config was changed",
|
||||
replaced: "{agent} was switched off {model} outside magpie",
|
||||
bypassed: "{agent} was used without going through magpie — restart it after applying",
|
||||
unreachable: "{agent} is set up, but nothing answers at the address it reaches magpie by",
|
||||
};
|
||||
|
||||
// an i in a circle: the pill that explains rather than acts
|
||||
const INFO_I = "M8 14.5a6.5 6.5 0 1 0 0-13 6.5 6.5 0 0 0 0 13zM8 7.25V11M8 5v.01";
|
||||
|
||||
const REAPPLY = "M13.5 8a5.5 5.5 0 1 1-1.6-3.9M13.5 2.5v3.25h-3.25";
|
||||
|
||||
// An unplugged plug: the agent taken off magpie.
|
||||
|
||||
@@ -221,6 +221,8 @@ const I18N = {
|
||||
"{agent} no longer goes through magpie — its config was changed": "{agent} 已不经过 magpie:配置已变更",
|
||||
"{agent} was switched off {model} outside magpie": "{agent} 已在 magpie 之外换掉 {model}",
|
||||
"{agent} was used without going through magpie — restart it after applying": "{agent} 最近绕过了 magpie,重新应用后请重启",
|
||||
"{agent} is set up, but nothing answers at the address it reaches magpie by": "{agent} 已设好,但它连 magpie 用的地址没有回应",
|
||||
"How to fix": "如何修复",
|
||||
"{agent} goes through magpie again": "{agent} 已重新经过 magpie",
|
||||
"Drag to reorder · click to move, hide or disconnect from magpie": "拖动以调整顺序 · 点击可移动、隐藏或断开 magpie",
|
||||
"Disconnect from magpie": "断开 magpie(还原配置)",
|
||||
@@ -3712,6 +3714,8 @@ const I18N = {
|
||||
"{agent} no longer goes through magpie — its config was changed": "{agent} は magpie を経由しなくなりました — 設定が変更されています",
|
||||
"{agent} was switched off {model} outside magpie": "{agent} は magpie の外で {model} から切り替えられました",
|
||||
"{agent} was used without going through magpie — restart it after applying": "{agent} は magpie を経由せずに使われました — 適用後に再起動してください",
|
||||
"{agent} is set up, but nothing answers at the address it reaches magpie by": "{agent} は設定済みですが、magpie につなぐアドレスから応答がありません",
|
||||
"How to fix": "直し方",
|
||||
"{agent} goes through magpie again": "{agent} は再び magpie を経由しています",
|
||||
"Drag to reorder · click to move, hide or disconnect from magpie": "ドラッグで並べ替え · クリックで移動、非表示、magpie からの切断",
|
||||
"Disconnect from magpie": "magpie から切断",
|
||||
@@ -7202,6 +7206,8 @@ const I18N = {
|
||||
"{agent} no longer goes through magpie — its config was changed": "{agent} läuft nicht mehr über magpie — seine Konfiguration wurde geändert",
|
||||
"{agent} was switched off {model} outside magpie": "{agent} wurde außerhalb von magpie von {model} weggeschaltet",
|
||||
"{agent} was used without going through magpie — restart it after applying": "{agent} wurde ohne magpie verwendet — nach dem Anwenden neu starten",
|
||||
"{agent} is set up, but nothing answers at the address it reaches magpie by": "{agent} ist eingerichtet, aber unter der Adresse, über die es magpie erreicht, antwortet nichts",
|
||||
"How to fix": "So beheben",
|
||||
"{agent} goes through magpie again": "{agent} läuft wieder über magpie",
|
||||
"Drag to reorder · click to move, hide or disconnect from magpie": "Ziehen zum Umsortieren · klicken zum Verschieben, Ausblenden oder Trennen von magpie",
|
||||
"Disconnect from magpie": "Von magpie trennen",
|
||||
|
||||
@@ -172,6 +172,17 @@ Set `ARTIFACT_DIR` to retain screenshots.
|
||||
node --test internal/gui/tests/agent-disconnect-preview.test.cjs
|
||||
```
|
||||
|
||||
`agent-unreachable.test.cjs` checks an agent whose config is right but whose
|
||||
address doesn't answer (#1013): its line says so in red with the advice as
|
||||
its tooltip, its pill is "How to fix", which opens the advice in a dialog and
|
||||
sets nothing, and when WSL reaches Windows at another address now the pill is
|
||||
"Use <address>" and posts the reapply. The window at 560px and the tray panel
|
||||
at 440px, in English, Chinese, Japanese and German on Chromium and WebKit.
|
||||
|
||||
```sh
|
||||
node --test internal/gui/tests/agent-unreachable.test.cjs
|
||||
```
|
||||
|
||||
`agent-layout.test.cjs` keeps the main window's agent names readable at 520,
|
||||
560 and 600 CSS pixels, while the model and effort controls stay inside their
|
||||
rows. At 601, the default 660 and 960 pixels, controls remain aligned beside
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
// Run with Node's test runner and Playwright on the module path; see README.md.
|
||||
// An agent connected by its config to an address where nothing answers
|
||||
// (#1013: Codex pointed at Windows as WSL sees it, through a portproxy gone
|
||||
// after a reboot) is no longer shown connected. Its row says it can't
|
||||
// reach magpie; its pill says how to fix it, in a dialog (the advice is too
|
||||
// long for the status line), and sets nothing. When WSL reaches Windows at
|
||||
// another address now, the pill is "Use <address>" and reconnects there.
|
||||
// The window at 560px and the tray panel at 440px; nothing runs off the
|
||||
// side. English, Chinese, Japanese and German, Chromium and WebKit.
|
||||
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");
|
||||
const kept = "http://172.28.96.1:3425";
|
||||
const detail = `Codex is pointed at magpie at ${kept}, where nothing answers from this computer, though magpie answers at http://127.0.0.1:3425. Under WSL's NAT networking, Windows as WSL sees it reaches magpie only while something listens on 172.28.96.1:3425: magpie itself with Settings › Share on local network on, or a forward of your own (netsh interface portproxy, which may need setting up again after Windows restarts).`;
|
||||
const codex = { id: "codex", name: "Codex", icon: "codex", path: "/c", wired: true,
|
||||
fields: [{ key: "model", label: "model", value: "fake/m1", options: [{ value: "fake/m1", label: "m1", ref: "fake/m1" }] }] };
|
||||
const filler = Array.from({ length: 4 }, (_, i) => ({ id: "pi" + i, name: "Pi " + i, path: "/p", fields: [{ key: "model", label: "model", value: "", options: [] }] }));
|
||||
|
||||
function serve(lang, move, calls) {
|
||||
const drift = { kind: "unreachable", field: "model", now: "fake/m1", want: "fake/m1", addr: kept, detail };
|
||||
if (move) drift.move = move;
|
||||
return async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` });
|
||||
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
|
||||
if (url.pathname === "/api/state") return route.fulfill({ json: { agents: [{ ...codex, drift }, ...filler], profiles: [], settings: { lang, theme: "light" } } });
|
||||
if (url.pathname === "/api/drift") return route.fulfill({ json: calls.length ? {} : { codex: drift } });
|
||||
if (url.pathname.startsWith("/api/agents/reapply/")) {
|
||||
calls.push(url.pathname);
|
||||
return route.fulfill({ json: { agents: [codex, ...filler], profiles: [], settings: { lang, theme: "light" } } });
|
||||
}
|
||||
if (url.pathname === "/api/plugins") return route.fulfill({ json: { plugins: [] } });
|
||||
if (url.pathname === "/api/usage/quotas") return route.fulfill({ json: [] });
|
||||
if (url.pathname === "/api/groups") return route.fulfill({ json: { groups: [] } });
|
||||
if (url.pathname === "/api/providers") return route.fulfill({ json: { providers: [], gateway: { running: true } } });
|
||||
if (url.pathname.startsWith("/api/")) return route.fulfill({ 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)];
|
||||
try { await route.fulfill({ body: await fs.readFile(file), contentType }); } catch { await route.fulfill({ status: 404, body: "" }); }
|
||||
};
|
||||
}
|
||||
|
||||
const words = {
|
||||
en: { why: "Codex is set up, but nothing answers at the address it reaches magpie by", fix: "How to fix", use: "Use 172.29.0.1:3425" },
|
||||
zh: { why: "Codex 已设好,但它连 magpie 用的地址没有回应", fix: "如何修复", use: "改用 172.29.0.1:3425" },
|
||||
ja: { why: "Codex は設定済みですが、magpie につなぐアドレスから応答がありません", fix: "直し方", use: "172.29.0.1:3425 を使う" },
|
||||
de: { why: "Codex ist eingerichtet, aber unter der Adresse, über die es magpie erreicht, antwortet nichts", fix: "So beheben", use: "172.29.0.1:3425 verwenden" },
|
||||
};
|
||||
const row = `.row.agent[data-id="codex"]`;
|
||||
const noSideScroll = (page) => page.evaluate(() => document.scrollingElement.scrollWidth <= innerWidth);
|
||||
|
||||
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
|
||||
for (const lang of ["en", "zh", "ja", "de"]) {
|
||||
const w = words[lang];
|
||||
test(`${engine} ${lang}: an address that doesn't answer is told, not connected`, async (t) => {
|
||||
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
|
||||
const errors = [];
|
||||
t.after(async () => {
|
||||
if (errors.length) console.log(errors);
|
||||
await browser.close();
|
||||
});
|
||||
const open = async (mode, move, calls) => {
|
||||
const page = await (await browser.newContext({ viewport: { width: mode ? 440 : 560, height: 640 }, reducedMotion: "reduce" })).newPage();
|
||||
page.setDefaultTimeout(5000);
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
await page.route("**/*", serve(lang, move, calls));
|
||||
await page.goto("http://magpie.test/" + (mode ? "?mode=" + mode : ""));
|
||||
await page.locator(`${row}.drifted .ag-fix`).waitFor();
|
||||
return page;
|
||||
};
|
||||
|
||||
for (const mode of ["", "panel"]) {
|
||||
await t.test(`${mode || "window"}: how to fix`, async () => {
|
||||
const calls = [];
|
||||
const page = await open(mode, "", calls);
|
||||
const fix = page.locator(`${row} .ag-fix`);
|
||||
assert.equal(await fix.getAttribute("aria-label"), w.fix);
|
||||
assert.match(await fix.getAttribute("title"), new RegExp(w.why));
|
||||
if (!mode) assert.equal((await fix.textContent()).trim(), w.fix);
|
||||
assert(await noSideScroll(page), "scrolls sideways");
|
||||
await fix.click();
|
||||
const dialog = page.locator("#modal:not([hidden])");
|
||||
await dialog.waitFor();
|
||||
assert.equal(await dialog.locator(".ehead b").textContent(), w.why);
|
||||
assert.match(await dialog.locator(".drift-why").textContent(), /portproxy/);
|
||||
const box = await dialog.locator(".editor").boundingBox();
|
||||
const width = mode ? 440 : 560;
|
||||
assert(box.x >= 0 && box.x + box.width <= width, `the dialog runs off: ${JSON.stringify(box)}`);
|
||||
assert(await noSideScroll(page), "scrolls sideways with the dialog");
|
||||
await dialog.locator(".bar button.primary").click();
|
||||
await page.waitForFunction(() => document.querySelector("#modal").hidden);
|
||||
assert.deepEqual(calls, [], "explaining set something");
|
||||
});
|
||||
}
|
||||
|
||||
await t.test("window: the line under the name", async () => {
|
||||
const page = await open("", "", []);
|
||||
// what is wrong, in red, never "Connected"; the advice its tooltip
|
||||
const st = page.locator(`${row} .ag-st`);
|
||||
assert.match(await st.getAttribute("class"), /\bbad\b/);
|
||||
assert.equal(await st.locator(".ag-st-t").textContent(), w.why);
|
||||
assert.match(await st.locator(".ag-st-t").getAttribute("title"), /Share on local network/);
|
||||
});
|
||||
|
||||
await t.test("window: use the address WSL has now", async () => {
|
||||
const calls = [];
|
||||
const page = await open("", "http://172.29.0.1:3425", calls);
|
||||
const fix = page.locator(`${row} .ag-fix`);
|
||||
assert.equal((await fix.textContent()).trim(), w.use);
|
||||
assert(await noSideScroll(page), "scrolls sideways");
|
||||
await fix.click();
|
||||
await page.waitForFunction(() => !document.querySelector('.row.agent[data-id="codex"] .ag-fix'));
|
||||
assert.deepEqual(calls, ["/api/agents/reapply/codex"]);
|
||||
});
|
||||
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user