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) }