From cd60142bfbd544ed34c69485ef72eba9621c5736 Mon Sep 17 00:00:00 2001 From: onedotmint Date: Sat, 15 Aug 2026 21:12:49 +0800 Subject: [PATCH] feat: read Pi config documents from WSL distro --- app.go | 4 + frontend/wailsjs/go/main/App.d.ts | 2 + frontend/wailsjs/go/main/App.js | 4 + frontend/wailsjs/go/models.ts | 20 ++ internal/wsl/pi_config.go | 99 +++++++ internal/wsl/pi_config_stub.go | 9 + internal/wsl/pi_config_test.go | 381 +++++++++++++++++++++++++ internal/wsl/pi_config_windows.go | 23 ++ internal/wsl/pi_config_windows_test.go | 57 ++++ 9 files changed, 599 insertions(+) create mode 100644 internal/wsl/pi_config.go create mode 100644 internal/wsl/pi_config_stub.go create mode 100644 internal/wsl/pi_config_test.go create mode 100644 internal/wsl/pi_config_windows.go create mode 100644 internal/wsl/pi_config_windows_test.go diff --git a/app.go b/app.go index 125dc40..9a75fea 100644 --- a/app.go +++ b/app.go @@ -256,6 +256,10 @@ func (a *App) GetWSLPiDetection(distro string) (wsl.PiDetection, error) { return wsl.DetectPi(distro) } +func (a *App) GetWSLPiConfigDocuments(distro string) (wsl.PiConfigDocuments, error) { + return wsl.ReadPiConfigDocuments(distro) +} + func (a *App) ListProviders() ([]provider.ConfigTransport, error) { cfg, err := a.coordinator.Load() if err != nil { diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index cac80eb..004abec 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -23,6 +23,8 @@ export function GetAppState():Promise; export function GetWSLDetection():Promise; +export function GetWSLPiConfigDocuments(arg1:string):Promise; + export function GetWSLPiDetection(arg1:string):Promise; export function ImportModels(arg1:string,arg2:Array):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index d58139e..6e05b9e 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -34,6 +34,10 @@ export function GetWSLDetection() { return window['go']['main']['App']['GetWSLDetection'](); } +export function GetWSLPiConfigDocuments(arg1) { + return window['go']['main']['App']['GetWSLPiConfigDocuments'](arg1); +} + export function GetWSLPiDetection(arg1) { return window['go']['main']['App']['GetWSLPiDetection'](arg1); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 4419310..1c2a13c 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -315,6 +315,26 @@ export namespace wsl { this.distros = source["distros"]; } } + export class PiConfigDocuments { + distro: string; + settingsExists: boolean; + modelsExists: boolean; + settingsJson: string; + modelsJson: string; + + static createFrom(source: any = {}) { + return new PiConfigDocuments(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.distro = source["distro"]; + this.settingsExists = source["settingsExists"]; + this.modelsExists = source["modelsExists"]; + this.settingsJson = source["settingsJson"]; + this.modelsJson = source["modelsJson"]; + } + } export class PiDetection { distro: string; home: string; diff --git a/internal/wsl/pi_config.go b/internal/wsl/pi_config.go new file mode 100644 index 0000000..443554f --- /dev/null +++ b/internal/wsl/pi_config.go @@ -0,0 +1,99 @@ +package wsl + +import ( + "fmt" + "io" + "os/exec" + "path" + "unicode/utf8" +) + +const piConfigDocumentLimit int64 = 1 << 23 + +type PiConfigDocuments struct { + Distro string `json:"distro"` + SettingsExists bool `json:"settingsExists"` + ModelsExists bool `json:"modelsExists"` + SettingsJSON string `json:"settingsJson"` + ModelsJSON string `json:"modelsJson"` +} + +type piConfigDetector func(string) (PiDetection, error) +type piConfigFileReader func(distro, linuxPath string) (string, error) + +func readPiConfigDocuments(distro string, detect piConfigDetector, readFile piConfigFileReader) (PiConfigDocuments, error) { + detection, err := detect(distro) + if err != nil { + return PiConfigDocuments{}, fmt.Errorf("detect WSL Pi configuration: %w", err) + } + + result := PiConfigDocuments{ + Distro: detection.Distro, + SettingsExists: detection.SettingsExists, + ModelsExists: detection.ModelsExists, + } + + if detection.SettingsExists { + settingsPath := path.Join(detection.PiHome, "agent", "settings.json") + content, err := readFile(distro, settingsPath) + if err != nil { + return PiConfigDocuments{}, fmt.Errorf("read WSL Pi document %q: %w", settingsPath, err) + } + result.SettingsJSON = content + } + + if detection.ModelsExists { + modelsPath := path.Join(detection.PiHome, "agent", "models.json") + content, err := readFile(distro, modelsPath) + if err != nil { + return PiConfigDocuments{}, fmt.Errorf("read WSL Pi document %q: %w", modelsPath, err) + } + result.ModelsJSON = content + } + + return result, nil +} + +func readBounded(r io.Reader, limit int64) ([]byte, error) { + data, err := io.ReadAll(io.LimitReader(r, limit+1)) + if err != nil { + return nil, err + } + if int64(len(data)) > limit { + return nil, fmt.Errorf("document exceeds %d bytes", limit) + } + return data, nil +} + +func decodePiConfigDocument(data []byte) (string, error) { + if !utf8.Valid(data) { + return "", fmt.Errorf("document is not valid UTF-8") + } + return string(data), nil +} + +func consumePiConfigCommand(cmd *exec.Cmd, limit int64) (string, error) { + stdout, err := cmd.StdoutPipe() + if err != nil { + return "", fmt.Errorf("open document stdout: %w", err) + } + if err := cmd.Start(); err != nil { + return "", fmt.Errorf("start document read: %w", err) + } + + data, readErr := readBounded(stdout, limit) + if readErr != nil { + _ = stdout.Close() + _ = cmd.Wait() + return "", readErr + } + if err := cmd.Wait(); err != nil { + return "", err + } + + text, err := decodePiConfigDocument(data) + if err != nil { + return "", err + } + return text, nil +} diff --git a/internal/wsl/pi_config_stub.go b/internal/wsl/pi_config_stub.go new file mode 100644 index 0000000..aa148da --- /dev/null +++ b/internal/wsl/pi_config_stub.go @@ -0,0 +1,9 @@ +//go:build !windows + +package wsl + +import "fmt" + +func ReadPiConfigDocuments(string) (PiConfigDocuments, error) { + return PiConfigDocuments{}, fmt.Errorf("WSL Pi configuration reading is supported only on Windows") +} diff --git a/internal/wsl/pi_config_test.go b/internal/wsl/pi_config_test.go new file mode 100644 index 0000000..56e11c8 --- /dev/null +++ b/internal/wsl/pi_config_test.go @@ -0,0 +1,381 @@ +package wsl + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "os/exec" + "path" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" +) + +func TestPiConfigDocumentLimit(t *testing.T) { + if piConfigDocumentLimit != 1<<23 { + t.Fatalf("piConfigDocumentLimit = %d, want %d", piConfigDocumentLimit, 1<<23) + } +} + +func TestPiConfigDocumentsJSON(t *testing.T) { + data, err := json.Marshal(PiConfigDocuments{Distro: "Ubuntu"}) + if err != nil { + t.Fatal(err) + } + want := `{"distro":"Ubuntu","settingsExists":false,"modelsExists":false,"settingsJson":"","modelsJson":""}` + if string(data) != want { + t.Fatalf("json.Marshal(PiConfigDocuments) = %s, want %s", data, want) + } +} + +func TestReadPiConfigDocuments(t *testing.T) { + const distro = "Ubuntu" + detection := PiDetection{ + Distro: distro, + PiHome: "/home/alice/.pi", + SettingsExists: true, + ModelsExists: true, + } + detectOK := func(string) (PiDetection, error) { return detection, nil } + + t.Run("discovery error skips reads", func(t *testing.T) { + called := false + got, err := readPiConfigDocuments(distro, func(string) (PiDetection, error) { + return PiDetection{}, errors.New("list failed") + }, func(string, string) (string, error) { + called = true + return "ignored", nil + }) + if err == nil || !strings.Contains(err.Error(), "list failed") { + t.Fatalf("error = %v, want wrapped discovery error", err) + } + if got != (PiConfigDocuments{}) { + t.Fatalf("result = %#v, want zero value", got) + } + if called { + t.Fatal("file read was attempted after a discovery error") + } + }) + + t.Run("both missing", func(t *testing.T) { + called := false + got, err := readPiConfigDocuments(distro, func(string) (PiDetection, error) { + return PiDetection{Distro: distro, PiHome: "/home/alice/.pi"}, nil + }, func(string, string) (string, error) { + called = true + return "ignored", nil + }) + if err != nil { + t.Fatal(err) + } + want := PiConfigDocuments{Distro: distro} + if got != want { + t.Fatalf("result = %#v, want %#v", got, want) + } + if called { + t.Fatal("file read was attempted for missing documents") + } + }) + + t.Run("settings only", func(t *testing.T) { + var paths []string + got, err := readPiConfigDocuments(distro, func(string) (PiDetection, error) { + return PiDetection{Distro: distro, PiHome: detection.PiHome, SettingsExists: true}, nil + }, func(_, linuxPath string) (string, error) { + paths = append(paths, linuxPath) + return `{"theme":"dark"}`, nil + }) + if err != nil { + t.Fatal(err) + } + want := PiConfigDocuments{ + Distro: distro, + SettingsExists: true, + SettingsJSON: `{"theme":"dark"}`, + } + if got != want { + t.Fatalf("result = %#v, want %#v", got, want) + } + if !reflect.DeepEqual(paths, []string{path.Join(detection.PiHome, "agent", "settings.json")}) { + t.Fatalf("read paths = %#v, want settings.json only", paths) + } + }) + + t.Run("models only", func(t *testing.T) { + var paths []string + got, err := readPiConfigDocuments(distro, func(string) (PiDetection, error) { + return PiDetection{Distro: distro, PiHome: detection.PiHome, ModelsExists: true}, nil + }, func(_, linuxPath string) (string, error) { + paths = append(paths, linuxPath) + return "[]", nil + }) + if err != nil { + t.Fatal(err) + } + want := PiConfigDocuments{ + Distro: distro, + ModelsExists: true, + ModelsJSON: "[]", + } + if got != want { + t.Fatalf("result = %#v, want %#v", got, want) + } + if !reflect.DeepEqual(paths, []string{path.Join(detection.PiHome, "agent", "models.json")}) { + t.Fatalf("read paths = %#v, want models.json only", paths) + } + }) + + t.Run("both present including empty and non-JSON", func(t *testing.T) { + var paths []string + got, err := readPiConfigDocuments(distro, detectOK, func(_, linuxPath string) (string, error) { + paths = append(paths, linuxPath) + if strings.HasSuffix(linuxPath, "settings.json") { + return "", nil + } + return "not json\n ", nil + }) + if err != nil { + t.Fatal(err) + } + want := PiConfigDocuments{ + Distro: distro, + SettingsExists: true, + ModelsExists: true, + ModelsJSON: "not json\n ", + } + if got != want { + t.Fatalf("result = %#v, want %#v", got, want) + } + wantPaths := []string{ + path.Join(detection.PiHome, "agent", "settings.json"), + path.Join(detection.PiHome, "agent", "models.json"), + } + if !reflect.DeepEqual(paths, wantPaths) { + t.Fatalf("read paths = %#v, want %#v", paths, wantPaths) + } + }) + + t.Run("paths with spaces stay independent argv values", func(t *testing.T) { + piHome := "/srv/users/first last/.pi" + var gotDistro, gotPath string + got, err := readPiConfigDocuments("Ubuntu 24.04 LTS", func(name string) (PiDetection, error) { + return PiDetection{Distro: name, PiHome: piHome, SettingsExists: true}, nil + }, func(name, linuxPath string) (string, error) { + gotDistro = name + gotPath = linuxPath + return "{}", nil + }) + if err != nil { + t.Fatal(err) + } + if got.Distro != "Ubuntu 24.04 LTS" || got.SettingsJSON != "{}" { + t.Fatalf("result = %#v, want spaced distro and raw content", got) + } + if gotDistro != "Ubuntu 24.04 LTS" { + t.Fatalf("reader distro = %q, want exact argv value", gotDistro) + } + if gotPath != path.Join(piHome, "agent", "settings.json") { + t.Fatalf("reader path = %q, want path.Join result", gotPath) + } + if strings.Contains(gotPath, `"`) || strings.Contains(gotDistro, "wsl.exe") { + t.Fatal("distro or path looks interpolated into a command string") + } + }) + + t.Run("read failure returns zero result", func(t *testing.T) { + got, err := readPiConfigDocuments(distro, detectOK, func(string, string) (string, error) { + return "partial", errors.New("cat failed") + }) + if err == nil || !strings.Contains(err.Error(), "cat failed") { + t.Fatalf("error = %v, want contextual read error", err) + } + if got != (PiConfigDocuments{}) { + t.Fatalf("result = %#v, want zero value", got) + } + }) + + t.Run("first file failure prevents second read", func(t *testing.T) { + var paths []string + got, err := readPiConfigDocuments(distro, detectOK, func(_, linuxPath string) (string, error) { + paths = append(paths, linuxPath) + return "partial", errors.New("permission denied") + }) + if err == nil || !strings.Contains(err.Error(), "permission denied") { + t.Fatalf("error = %v, want first-file read error", err) + } + if got != (PiConfigDocuments{}) { + t.Fatalf("result = %#v, want zero value", got) + } + if len(paths) != 1 || paths[0] != path.Join(detection.PiHome, "agent", "settings.json") { + t.Fatalf("read paths = %#v, want only the first present file", paths) + } + }) + + t.Run("second file failure discards first content", func(t *testing.T) { + got, err := readPiConfigDocuments(distro, detectOK, func(_, linuxPath string) (string, error) { + if strings.HasSuffix(linuxPath, "settings.json") { + return `{"ok":true}`, nil + } + return "partial", errors.New("models read failed") + }) + if err == nil || !strings.Contains(err.Error(), "models read failed") { + t.Fatalf("error = %v, want second-file read error", err) + } + if got != (PiConfigDocuments{}) { + t.Fatalf("result = %#v, want zero value", got) + } + }) +} + +func TestReadBounded(t *testing.T) { + t.Run("under limit", func(t *testing.T) { + input := []byte("abcdef") + got, err := readBounded(bytes.NewReader(input), 8) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, input) { + t.Fatalf("readBounded() = %q, want %q", got, input) + } + }) + + t.Run("exact limit", func(t *testing.T) { + input := bytes.Repeat([]byte("a"), 8) + got, err := readBounded(bytes.NewReader(input), 8) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, input) { + t.Fatalf("readBounded() = %q, want %q", got, input) + } + }) + + t.Run("overflow does not slurp the source", func(t *testing.T) { + const limit int64 = 8 + source := bytes.NewReader(bytes.Repeat([]byte("a"), 1024)) + got, err := readBounded(source, limit) + if err == nil || !strings.Contains(err.Error(), "exceeds") { + t.Fatalf("error = %v, want size error", err) + } + if got != nil { + t.Fatalf("data = %q, want nil", got) + } + if source.Len() == 0 { + t.Fatal("readBounded buffered the whole source") + } + if unread := source.Len(); unread != 1024-int(limit+1) { + t.Fatalf("unread bytes = %d, want %d", unread, 1024-int(limit+1)) + } + }) +} + +func TestDecodePiConfigDocument(t *testing.T) { + got, err := decodePiConfigDocument([]byte("{\n \"ok\": true\n}")) + if err != nil { + t.Fatal(err) + } + if got != "{\n \"ok\": true\n}" { + t.Fatalf("decodePiConfigDocument() = %q, want raw text", got) + } + + empty, err := decodePiConfigDocument(nil) + if err != nil || empty != "" { + t.Fatalf("decodePiConfigDocument(nil) = %q, %v, want empty success", empty, err) + } + + if _, err := decodePiConfigDocument([]byte{0xff, 0xfe}); err == nil || !strings.Contains(err.Error(), "UTF-8") { + t.Fatalf("error = %v, want invalid UTF-8 error", err) + } +} + +func TestConsumePiConfigCommand(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("local cat-based document reads are exercised on POSIX hosts") + } + + writeDoc := func(t *testing.T, name, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return path + } + + t.Run("raw contents", func(t *testing.T) { + path := writeDoc(t, "settings.json", "{\n \"ok\": true\n}") + got, err := consumePiConfigCommand(exec.Command("cat", path), 1024) + if err != nil { + t.Fatal(err) + } + if got != "{\n \"ok\": true\n}" { + t.Fatalf("content = %q, want raw file text", got) + } + }) + + t.Run("empty present file", func(t *testing.T) { + path := writeDoc(t, "empty.json", "") + got, err := consumePiConfigCommand(exec.Command("cat", path), 1024) + if err != nil { + t.Fatal(err) + } + if got != "" { + t.Fatalf("content = %q, want empty string", got) + } + }) + + t.Run("process failure", func(t *testing.T) { + got, err := consumePiConfigCommand(exec.Command("cat", filepath.Join(t.TempDir(), "missing.json")), 1024) + if err == nil { + t.Fatal("error = nil, want process error") + } + if got != "" { + t.Fatalf("content = %q, want empty string", got) + } + }) + + t.Run("invalid UTF-8", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "binary.json") + if err := os.WriteFile(path, []byte{0xff, 0xfe, 'a'}, 0o644); err != nil { + t.Fatal(err) + } + got, err := consumePiConfigCommand(exec.Command("cat", path), 1024) + if err == nil || !strings.Contains(err.Error(), "UTF-8") { + t.Fatalf("error = %v, want invalid UTF-8 error", err) + } + if got != "" { + t.Fatalf("content = %q, want empty string", got) + } + }) + + t.Run("overflow prefers size error", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "big.json") + if err := os.WriteFile(path, bytes.Repeat([]byte("a"), 256*1024), 0o644); err != nil { + t.Fatal(err) + } + got, err := consumePiConfigCommand(exec.Command("cat", path), 32) + if err == nil || !strings.Contains(err.Error(), "exceeds") { + t.Fatalf("error = %v, want size error", err) + } + if got != "" { + t.Fatalf("content = %q, want empty string", got) + } + }) +} + +func TestReadPiConfigDocuments_NonWindows(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("the Windows implementation requires a WSL installation") + } + + got, err := ReadPiConfigDocuments("Ubuntu") + if err == nil || !strings.Contains(err.Error(), "supported only on Windows") { + t.Fatalf("error = %v, want unsupported error", err) + } + if got != (PiConfigDocuments{}) { + t.Fatalf("result = %#v, want zero value", got) + } +} diff --git a/internal/wsl/pi_config_windows.go b/internal/wsl/pi_config_windows.go new file mode 100644 index 0000000..10a897a --- /dev/null +++ b/internal/wsl/pi_config_windows.go @@ -0,0 +1,23 @@ +//go:build windows + +package wsl + +import ( + "os/exec" + "syscall" +) + +const piConfigCreateNoWindow uint32 = 0x08000000 + +var piConfigCommand = exec.Command + +func ReadPiConfigDocuments(distro string) (PiConfigDocuments, error) { + return readPiConfigDocuments(distro, DetectPi, readPiConfigFile) +} + +func readPiConfigFile(distro, linuxPath string) (string, error) { + cmd := piConfigCommand("wsl.exe", "--distribution", distro, "--exec", "cat", linuxPath) + // Prevent a console window for this noninteractive WSL read. + cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: piConfigCreateNoWindow} + return consumePiConfigCommand(cmd, piConfigDocumentLimit) +} diff --git a/internal/wsl/pi_config_windows_test.go b/internal/wsl/pi_config_windows_test.go new file mode 100644 index 0000000..0c04910 --- /dev/null +++ b/internal/wsl/pi_config_windows_test.go @@ -0,0 +1,57 @@ +//go:build windows + +package wsl + +import ( + "os/exec" + "reflect" + "testing" +) + +func TestReadPiConfigFileCommand(t *testing.T) { + original := piConfigCommand + defer func() { piConfigCommand = original }() + + tests := []struct { + name string + distro string + linuxPath string + }{ + { + name: "simple path", + distro: "Ubuntu", + linuxPath: "/home/alice/.pi/agent/settings.json", + }, + { + name: "spaces stay independent argv values", + distro: "Ubuntu 24.04 LTS", + linuxPath: "/home/first last/.pi/agent/models.json", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var gotName string + var gotArgs []string + var gotCommand *exec.Cmd + piConfigCommand = func(name string, args ...string) *exec.Cmd { + gotName = name + gotArgs = append([]string(nil), args...) + gotCommand = exec.Command("cmd", "/c", "exit", "0") + return gotCommand + } + + if _, err := readPiConfigFile(test.distro, test.linuxPath); err != nil { + t.Fatal(err) + } + + wantArgs := []string{"--distribution", test.distro, "--exec", "cat", test.linuxPath} + if gotName != "wsl.exe" || !reflect.DeepEqual(gotArgs, wantArgs) { + t.Errorf("readPiConfigFile() command = %q %#v, want %q %#v", gotName, gotArgs, "wsl.exe", wantArgs) + } + if gotCommand.SysProcAttr == nil || gotCommand.SysProcAttr.CreationFlags != piConfigCreateNoWindow { + t.Errorf("readPiConfigFile() CreationFlags = %#v, want %#v", gotCommand.SysProcAttr, piConfigCreateNoWindow) + } + }) + } +}