From 31626c15434ee653afb4f85e1ab1e962cf997ccf Mon Sep 17 00:00:00 2001 From: yetone Date: Mon, 5 Oct 2026 14:35:18 +0800 Subject: [PATCH] =?UTF-8?q?agent:=20an=20omp=20in=20WSL=20under=20NAT=20ge?= =?UTF-8?q?ts=20the=20gateway's=20network=20key,=20and=20one=20bun=20insta?= =?UTF-8?q?lled=20is=20found=20and=20asked=20its=20version=20(whqtian=20on?= =?UTF-8?q?=20Discord:=20a=20WSL=20omp=20connected=20only=20once=20auth:?= =?UTF-8?q?=20none=20was=20swapped=20for=20the=20key=20by=20hand,=20and=20?= =?UTF-8?q?Kimi=20K3's=20max=20still=20read=20xhigh)=20=E2=80=94=20a=20dis?= =?UTF-8?q?tro=20not=20in=20mirrored=20networking=20reaches=20the=20gatewa?= =?UTF-8?q?y=20at=20Windows'=20address=20(172.23.80.1),=20where=20a=20requ?= =?UTF-8?q?est=20without=20a=20named=20key=20is=20turned=20away,=20yet=20m?= =?UTF-8?q?agpie=20wrote=20auth:=20none=20there=20too.=20Its=20models.yml?= =?UTF-8?q?=20now=20carries=20the=20key=20the=20gateway=20is=20shared=20on?= =?UTF-8?q?=20the=20network=20with=20(access.LANSecret:=20the=20default=20?= =?UTF-8?q?LAN=20key,=20""=20while=20nothing=20is=20shared=20or=20it=20is?= =?UTF-8?q?=20off)=20in=20place=20of=20auth:=20none;=20an=20omp=20on=20loo?= =?UTF-8?q?pback=20stays=20keyless.=20The=20probe=20asked=20omp=20its=20ve?= =?UTF-8?q?rsion=20only=20when=20sh=20-l=20found=20it=20on=20PATH,=20but?= =?UTF-8?q?=20bun's=20and=20nvm's=20installers=20put=20theirs=20on=20PATH?= =?UTF-8?q?=20in=20~/.bashrc,=20which=20sh=20-l=20doesn't=20read:=20the=20?= =?UTF-8?q?version=20stayed=20unknown=20and=20max=20was=20offered=20as=20x?= =?UTF-8?q?high,=20as=20for=20an=20omp=20older=20than=2016.4.0.=20It=20now?= =?UTF-8?q?=20also=20looks=20in=20~/.bun/bin,=20~/.local/bin,=20~/.npm-glo?= =?UTF-8?q?bal/bin,=20~/.volta/bin,=20pnpm's,=20nvm's,=20mise's=20and=20as?= =?UTF-8?q?df's=20folders,=20and=20runs=20--version=20with=20the=20command?= =?UTF-8?q?'s=20folder=20on=20PATH,=20where=20bun's=20#!/usr/bin/env=20bun?= =?UTF-8?q?=20finds=20bun.=20TestOmpKeyBeyondLoopback=20(NAT=20address=20s?= =?UTF-8?q?hared=20or=20not,=20loopback,=20localhost)=20and=20TestWSLProbe?= =?UTF-8?q?FindsBunOmp=20(the=20probe=20script=20run=20with=20a=20bun-inst?= =?UTF-8?q?alled=20omp=20off=20PATH;=20fails=20on=20the=20old=20script:=20?= =?UTF-8?q?no=20bin,=20no=20version).=20go=20vet,=20builds=20(darwin,=20GO?= =?UTF-8?q?OS=3Dlinux=20nogui,=20GOOS=3Dwindows)=20and=20go=20test=20-tags?= =?UTF-8?q?=20nogui=20-count=3D1=20./...=20under=20a=20temp=20HOME=20pass?= =?UTF-8?q?=20but=20for=20TestSavedButSignedOutClaudeServed,=20which=20tim?= =?UTF-8?q?ed=20out=20once=20under=20load=20and=20passes=203=20of=203=20al?= =?UTF-8?q?one.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/access/access.go | 28 +++++++++ internal/agent/omo_test.go | 2 +- internal/agent/omp.go | 19 +++++- internal/agent/omp_wsl_key_test.go | 96 ++++++++++++++++++++++++++++++ internal/agent/omp_wsl_test.go | 2 +- internal/agent/wsl.go | 13 +++- internal/agent/wsl_claude_test.go | 2 +- internal/agent/wsl_pi_test.go | 4 +- 8 files changed, 155 insertions(+), 11 deletions(-) create mode 100644 internal/agent/omp_wsl_key_test.go diff --git a/internal/access/access.go b/internal/access/access.go index 5a6fada8..8cba9c1d 100644 --- a/internal/access/access.go +++ b/internal/access/access.go @@ -92,6 +92,34 @@ func List() ([]Key, error) { return keys, nil } +// LANSecret is the key magpie shares the gateway on the local network with +// (the default key ConfigureLAN made), for an agent of this computer's that +// reaches it from beyond loopback — one in a WSL distro under NAT, whose +// requests the gateway takes only with a named key; "" while the gateway +// isn't shared, or that key is off. +func LANSecret() string { + mu.Lock() + defer mu.Unlock() + s := settings.Load() + if !s.LAN { + return "" + } + keys, err := load() + if err != nil { + return "" + } + if i := slices.IndexFunc(keys, func(k Key) bool { return k.ID == s.LANKeyID || k.LAN }); i >= 0 { + if keys[i].Off { + return "" + } + return keys[i].Secret + } + if s.LANKeyID == "" && s.LANKey != "" && !strings.HasPrefix(s.LANKey, revokedLANPrefix) { + return s.LANKey + } + return "" +} + // Export returns credentials only for the encrypted backup bundle. func Export() ([]Key, error) { mu.Lock() diff --git a/internal/agent/omo_test.go b/internal/agent/omo_test.go index cad89d1e..b8c2a6ae 100644 --- a/internal/agent/omo_test.go +++ b/internal/agent/omo_test.go @@ -112,7 +112,7 @@ func TestOmoDir(t *testing.T) { // The WSL probe asks after OmO too. func TestWSLProbeFindsOmo(t *testing.T) { - for _, want := range []string{`[ -d "$HOME/.omo" ] && echo dir:.omo`, `p=$(command -v omo 2>/dev/null) && echo "bin:omo $p"`} { + for _, want := range []string{`[ -d "$HOME/.omo" ] && echo dir:.omo`, `p=$(command -v omo 2>/dev/null || wslbin omo) && echo "bin:omo $p"`} { if !strings.Contains(wslProbeScript, want) { t.Errorf("probe lacks %q", want) } diff --git a/internal/agent/omp.go b/internal/agent/omp.go index 075405ad..21c9e4c3 100644 --- a/internal/agent/omp.go +++ b/internal/agent/omp.go @@ -4,7 +4,7 @@ package agent // (or where ompDir says its variables move it), // the model of each role under modelRoles as "provider/model", and providers // of the user's own in models.yml beside it. magpie adds itself there as the -// provider "magpie", keyless (auth: none), with the catalog as its models; a +// provider "magpie", keyless (auth: none) on loopback, with the catalog as its models; a // model through magpie is "magpie//", which omp matches // whole against provider/id. The other roles, the fallback chains and the // like may name them as well (ompRefKeys); magpie stays while any does. @@ -17,6 +17,7 @@ import ( "sort" "strings" + "github.com/yetone/magpie/internal/access" "github.com/yetone/magpie/internal/appdir" "github.com/yetone/magpie/internal/catalog" "github.com/yetone/magpie/internal/edit" @@ -386,7 +387,11 @@ type ompThinking struct { type ompProviderEntry struct { BaseURL string `yaml:"baseUrl"` API string `yaml:"api"` - Auth string `yaml:"auth"` + // Auth is none on loopback, where the gateway asks for no key; APIKey, + // for an omp that reaches it from beyond (a WSL distro under NAT), the + // key the gateway is shared on the network with + Auth string `yaml:"auth,omitempty"` + APIKey string `yaml:"apiKey,omitempty"` // Headers name omp to the gateway: omp 16.x asks with Bun's User-Agent // and only a later one with its own (omp/18.4.4), so its requests went // to "Bun" in usage and past omp's own rules and stand-ins @@ -471,8 +476,16 @@ func ompProviderAt(gw, version string) ompProviderEntry { } ms = append(ms, e) } - return ompProviderEntry{BaseURL: gw + "/v1", API: "openai-completions", Auth: "none", + e := ompProviderEntry{BaseURL: gw + "/v1", API: "openai-completions", Auth: "none", Headers: map[string]string{"User-Agent": "omp"}, Models: ms} + // whqtian on Discord: a WSL omp under NAT asks Windows' address, where + // the gateway turns a request without a named key away + if onAnotherMachine(gw) { + if key := access.LANSecret(); key != "" { + e.Auth, e.APIKey = "", key + } + } + return e } // ompOwnOptions lists the models of the providers the user added to omp's diff --git a/internal/agent/omp_wsl_key_test.go b/internal/agent/omp_wsl_key_test.go new file mode 100644 index 00000000..bd04aeb4 --- /dev/null +++ b/internal/agent/omp_wsl_key_test.go @@ -0,0 +1,96 @@ +package agent + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/yetone/magpie/internal/access" + "gopkg.in/yaml.v3" +) + +// A WSL omp under NAT reaches the gateway at Windows' address, where a +// request without a named key is turned away (whqtian on Discord: it +// connected only once auth: none was swapped for the key by hand). Its +// models.yml carries the key the gateway is shared on the network with; +// one on loopback stays keyless, and so does one while nothing is shared. +func TestOmpKeyBeyondLoopback(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("XDG_CACHE_HOME", filepath.Join(home, ".cache")) + wsl := "http://172.23.80.1:3425" + if e := ompProviderAt(wsl, "18.4.8"); e.Auth != "none" || e.APIKey != "" { + t.Fatalf("not shared, yet auth %q key %q", e.Auth, e.APIKey) + } + if err := access.ConfigureLAN(true, false); err != nil { + t.Fatal(err) + } + key := access.LANSecret() + if !strings.HasPrefix(key, access.Prefix) { + t.Fatalf("LAN key %q", key) + } + e := ompProviderAt(wsl, "18.4.8") + if e.APIKey != key || e.Auth != "" { + t.Fatalf("WSL omp under NAT: auth %q key %q, want the LAN key", e.Auth, e.APIKey) + } + b, _ := yaml.Marshal(e) + if strings.Contains(string(b), "auth:") || !strings.Contains(string(b), "apiKey: "+key) { + t.Errorf("models.yml entry:\n%s", b) + } + for _, gw := range []string{"http://127.0.0.1:3425", "http://localhost:3425"} { + if e := ompProviderAt(gw, "18.4.8"); e.Auth != "none" || e.APIKey != "" { + t.Errorf("%s: auth %q key %q", gw, e.Auth, e.APIKey) + } + } + if err := access.ConfigureLAN(false, false); err != nil { + t.Fatal(err) + } + if e := ompProviderAt(wsl, "18.4.8"); e.Auth != "none" || e.APIKey != "" { + t.Errorf("sharing off, yet auth %q key %q", e.Auth, e.APIKey) + } +} + +// The probe finds an omp bun installed though ~/.bun/bin is put on PATH +// only by ~/.bashrc, which sh -l doesn't read, and asks it its version +// with bun on PATH for its #!/usr/bin/env bun (whqtian on Discord: a WSL +// omp's version stayed unknown, so max stayed xhigh). +func TestWSLProbeFindsBunOmp(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("the probe runs in a Linux distro") + } + to, err := exec.LookPath("timeout") + if err != nil { + t.Skip("no timeout(1)") + } + home := t.TempDir() + bun := filepath.Join(home, ".bun", "bin") + sys := filepath.Join(home, "sys") // PATH: only timeout, nothing of omp's + for _, d := range []string{bun, sys, filepath.Join(home, ".omp")} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + os.WriteFile(filepath.Join(bun, "bun"), []byte("#!/bin/sh\necho omp/16.5.1\n"), 0o755) + os.WriteFile(filepath.Join(bun, "omp"), []byte("#!/usr/bin/env bun\n"), 0o755) + if err := os.Symlink(to, filepath.Join(sys, "timeout")); err != nil { + t.Fatal(err) + } + cmd := exec.Command("/bin/sh", "-c", wslProbeScript) + cmd.Env = []string{"HOME=" + home, "PATH=" + sys + ":/usr/bin:/bin"} + out, err := cmd.Output() + if err != nil { + t.Fatalf("%v: %s", err, out) + } + d := parseProbe("U", string(out)) + if d == nil || !d.Has["bin:omp"] || d.Versions["omp"] != "16.5.1" { + t.Fatalf("probe said:\n%s\nread as %+v", out, d) + } + if !ompTakesMax(d.place("omp@wsl:U").version) { + t.Error("its models wouldn't offer max") + } +} diff --git a/internal/agent/omp_wsl_test.go b/internal/agent/omp_wsl_test.go index c3c904b9..aa546fd1 100644 --- a/internal/agent/omp_wsl_test.go +++ b/internal/agent/omp_wsl_test.go @@ -11,7 +11,7 @@ import ( // One on Windows' drives isn't asked, and one that says no version is // taken for an older omp as before. func TestWSLOmpVersionProbed(t *testing.T) { - if !strings.Contains(wslProbeScript, `echo "ver:omp $(timeout 10 "$p" --version`) || !strings.Contains(wslProbeScript, `/mnt/*) ;;`) { + if !strings.Contains(wslProbeScript, `echo "ver:omp $(PATH="${p%/*}:$PATH" timeout 10 "$p" --version`) || !strings.Contains(wslProbeScript, `/mnt/*) ;;`) { t.Fatalf("the probe doesn't ask omp its version: %s", wslProbeScript) } if strings.Contains(wslProbeScript, `"ver:codex`) { diff --git a/internal/agent/wsl.go b/internal/agent/wsl.go index b496609e..dfcd397a 100644 --- a/internal/agent/wsl.go +++ b/internal/agent/wsl.go @@ -779,15 +779,22 @@ const wslProbeVersion = 2 // its default route (the Windows host under NAT), and WSL's networking // mode as wslinfo (WSL 2.0 on) says it; and the version of each that asks // for it (omp, whose models.yml takes max only from 16.4.0). +// +// A command is also looked for in the folders its installers put it in +// (wslbin): sh -l reads ~/.profile but not ~/.bashrc, where bun's and +// nvm's installers add theirs to PATH (whqtian on Discord: a WSL omp from +// bun was never asked its version, so its models still offered xhigh). +// It is asked with its own folder on PATH, where bun's script finds bun. var wslProbeScript = func() string { - s := `echo "home:$HOME"; ` + s := `wslbin() { for d in "$HOME/.bun/bin" "$HOME/.local/bin" "$HOME/.npm-global/bin" "$HOME/.volta/bin" "$HOME/.local/share/pnpm" "$HOME"/.nvm/versions/node/*/bin "$HOME/.local/share/mise/shims" "$HOME/.asdf/shims"; do [ -x "$d/$1" ] && { echo "$d/$1"; return 0; }; done; return 1; }; ` + s += `echo "home:$HOME"; ` for _, k := range wslKinds { s += `[ -d "$HOME/` + k.dir + `" ] && echo dir:` + k.dir + `; ` if k.bin != "" { - s += `p=$(command -v ` + k.bin + ` 2>/dev/null) && echo "bin:` + k.bin + ` $p"` + s += `p=$(command -v ` + k.bin + ` 2>/dev/null || wslbin ` + k.bin + `) && echo "bin:` + k.bin + ` $p"` if k.version { // not one on Windows' drives (WSL's /mnt/c), which isn't the distro's - s += ` && case "$p" in /mnt/*) ;; *) echo "ver:` + k.id + ` $(timeout 10 "$p" --version &1 | head -n1)";; esac` + s += ` && case "$p" in /mnt/*) ;; *) echo "ver:` + k.id + ` $(PATH="${p%/*}:$PATH" timeout 10 "$p" --version &1 | head -n1)";; esac` } s += `; ` } diff --git a/internal/agent/wsl_claude_test.go b/internal/agent/wsl_claude_test.go index fbaf41ee..c336ecdb 100644 --- a/internal/agent/wsl_claude_test.go +++ b/internal/agent/wsl_claude_test.go @@ -44,7 +44,7 @@ func noOwnClaude(t *testing.T) { // The probe asks after Claude Code as after Codex and Pi, and reads what // it says. func TestWSLProbeFindsClaude(t *testing.T) { - for _, want := range []string{`[ -d "$HOME/.claude" ] && echo dir:.claude`, `p=$(command -v claude 2>/dev/null) && echo "bin:claude $p"`, + for _, want := range []string{`[ -d "$HOME/.claude" ] && echo dir:.claude`, `p=$(command -v claude 2>/dev/null || wslbin claude) && echo "bin:claude $p"`, `[ -d "$HOME/.pi" ] && echo dir:.pi`, `[ -d "$HOME/.codex" ] && echo dir:.codex`} { if !strings.Contains(wslProbeScript, want) { t.Errorf("probe lacks %q:\n%s", want, wslProbeScript) diff --git a/internal/agent/wsl_pi_test.go b/internal/agent/wsl_pi_test.go index cdb0310e..100e2744 100644 --- a/internal/agent/wsl_pi_test.go +++ b/internal/agent/wsl_pi_test.go @@ -30,8 +30,8 @@ func piDistroHome(t *testing.T, settings string) (root, home string) { // The probe asks after Pi as after Codex, and reads what it says. func TestWSLProbeFindsPi(t *testing.T) { - for _, want := range []string{`[ -d "$HOME/.pi" ] && echo dir:.pi`, `p=$(command -v pi 2>/dev/null) && echo "bin:pi $p"`, - `[ -d "$HOME/.codex" ] && echo dir:.codex`, `p=$(command -v codex 2>/dev/null) && echo "bin:codex $p"`} { + for _, want := range []string{`[ -d "$HOME/.pi" ] && echo dir:.pi`, `p=$(command -v pi 2>/dev/null || wslbin pi) && echo "bin:pi $p"`, + `[ -d "$HOME/.codex" ] && echo dir:.codex`, `p=$(command -v codex 2>/dev/null || wslbin codex) && echo "bin:codex $p"`} { if !strings.Contains(wslProbeScript, want) { t.Errorf("probe lacks %q:\n%s", want, wslProbeScript) }