From 43f0118010c4c0f7ded80d05e2490b87eb50e24d Mon Sep 17 00:00:00 2001 From: Gnani Rahul Date: Tue, 14 Jul 2026 17:31:35 -0500 Subject: [PATCH 1/7] feat(f11): import kubeconfig directories in local UI Add a bounded, in-memory kubeconfig directory importer for the loopback fleet IDE. Imported contexts retain unique identities, safe relative source labels, and per-file diagnostics while preserving the direct local-operation path. GSTACK-Checkpoint: 2026-07-14/f11-kubeconfig-directory-import#1 Signed-off-by: Gnani Rahul --- README.md | 11 +- internal/cli/local_test.go | 14 + internal/cli/ui.go | 16 +- internal/connector/contract.go | 22 +- internal/connector/kubeconfig/adapter.go | 70 ++++- internal/connector/kubeconfig/directory.go | 248 ++++++++++++++++++ .../connector/kubeconfig/directory_test.go | 209 +++++++++++++++ .../connector/kubeconfig/local_objects.go | 6 +- internal/fleetcache/store.go | 47 ++-- internal/fleetcache/store_test.go | 19 ++ internal/webui/assets/app.css | 2 + internal/webui/assets/app.js | 27 +- internal/webui/server_test.go | 21 ++ ...6-07-14-f11-kubeconfig-directory-import.md | 20 ++ tests/e2e/kind_fanout_test.go | 31 +++ tests/e2e/kind_web_ui_test.go | 129 +++++++++ 16 files changed, 850 insertions(+), 42 deletions(-) create mode 100644 internal/connector/kubeconfig/directory.go create mode 100644 internal/connector/kubeconfig/directory_test.go create mode 100644 sessions/2026-07-14-f11-kubeconfig-directory-import.md diff --git a/README.md b/README.md index e4e370c..415c312 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ make build ./bin/sith port-forward service/api --context kind-dev -n apps :http ./bin/sith edit configmap/api-settings --context kind-dev -n apps ./bin/sith ui # loopback-only embedded fleet IDE +./bin/sith ui --kubeconfig-dir "$HOME/kubeconfigs" # import a folder of kubeconfig files for this UI session ./bin/sith serve --mcp # loopback-only MCP read server ./bin/sith serve --mcp --require-token ``` @@ -332,7 +333,15 @@ component dependency enters the binary. `sith ui` serves a build-free frontend embedded in the same Go binary. It binds to `127.0.0.1` on an available port by default; `--address` accepts loopback addresses only and -`--no-open` suppresses browser launch. The browser renders the same cache, lenses, ordering, +`--no-open` suppresses browser launch. `--kubeconfig-dir ` imports a bounded recursive +set of regular kubeconfig files for that UI session. It does not replace the standard +`KUBECONFIG`/`~/.kube/config` mode, write or persist a config, or follow directory symlinks. The +supplied root must be an existing real directory; a file, symlink, or missing root fails before the +local listener starts. Invalid, oversized, unreadable, or symlinked entries beneath a valid root +are skipped with safe warnings that do not expose kubeconfig contents or an absolute local path. +Each imported source is labeled by its relative filename; contexts with the same name remain +isolated, and selecting a source in the context rail filters to its contexts. The import is limited +to 128 files, 4 MiB per file, and eight nested directory levels. The browser renders the same cache, lenses, ordering, coverage, search/correlation grammar, and per-resource operations as the CLI/TUI. Its local HTTP boundary requires an exact Host/Origin and a per-process capability header, uses a restrictive Content Security Policy, and loads no remote assets. YAML apply additionally requires a short-lived, diff --git a/internal/cli/local_test.go b/internal/cli/local_test.go index 2248e4e..9c913aa 100644 --- a/internal/cli/local_test.go +++ b/internal/cli/local_test.go @@ -182,6 +182,20 @@ func TestUIRefusesExternalBindAndStartsOnLoopback(t *testing.T) { } } +func TestUIKubeconfigDirectoryRefusesFileBeforeStartingListener(t *testing.T) { + filename := filepath.Join(t.TempDir(), "not-a-directory") + if err := os.WriteFile(filename, []byte("apiVersion: v1\n"), 0o600); err != nil { + t.Fatal(err) + } + stdout, stderr, exitCode := runUICLI(context.Background(), t, []string{ + "ui", "--no-open", "--kubeconfig-dir", filename, + }, &cacheReader{}, &fakeLocalClient{}) + if exitCode == 0 || stdout != "" || !strings.Contains(stderr, "must be a real directory") || + strings.Contains(stderr, filename) || strings.Contains(stderr, "apiVersion: v1") { + t.Fatalf("directory refusal exit/stdout/stderr = %d/%q/%q", exitCode, stdout, stderr) + } +} + func runLocalCLI( t *testing.T, args []string, diff --git a/internal/cli/ui.go b/internal/cli/ui.go index 6901b23..edfcabb 100644 --- a/internal/cli/ui.go +++ b/internal/cli/ui.go @@ -16,6 +16,7 @@ import ( "github.com/spf13/cobra" "github.com/ArdurAI/sith/internal/connector" + "github.com/ArdurAI/sith/internal/connector/kubeconfig" "github.com/ArdurAI/sith/internal/fleetcache" "github.com/ArdurAI/sith/internal/hydrate" "github.com/ArdurAI/sith/internal/localops" @@ -23,9 +24,10 @@ import ( ) type uiOptions struct { - address string - port int - noOpen bool + address string + port int + noOpen bool + kubeconfigDir string } func newUICommand(reader connector.Reader, local localops.Client) *cobra.Command { @@ -38,12 +40,20 @@ func newUICommand(reader connector.Reader, local localops.Client) *cobra.Command if reader == nil || local == nil { return fmt.Errorf("local fleet UI requires a Kubernetes reader and local operations client") } + if options.kubeconfigDir != "" { + adapter, err := kubeconfig.New(kubeconfig.WithDirectory(options.kubeconfigDir)) + if err != nil { + return fmt.Errorf("import kubeconfig directory: %w", err) + } + reader, local = adapter, adapter + } return runWebUI(command.Context(), command, reader, local, options) }, } command.Flags().StringVar(&options.address, "address", options.address, "loopback listen address") command.Flags().IntVar(&options.port, "port", 0, "loopback listen port; 0 selects an available port") command.Flags().BoolVar(&options.noOpen, "no-open", false, "do not open the system browser") + command.Flags().StringVar(&options.kubeconfigDir, "kubeconfig-dir", "", "import kubeconfig files from this directory for this local UI session") return command } diff --git a/internal/connector/contract.go b/internal/connector/contract.go index f9b7980..0ec7f75 100644 --- a/internal/connector/contract.go +++ b/internal/connector/contract.go @@ -115,16 +115,26 @@ type WatchEvent struct { // Discovery describes the scopes a reader can currently address. type Discovery struct { - Scopes []Scope `json:"scopes"` - Unreachable []string `json:"unreachable,omitempty"` + Scopes []Scope `json:"scopes"` + Unreachable []string `json:"unreachable,omitempty"` + Diagnostics []Diagnostic `json:"diagnostics,omitempty"` } // Scope is one cluster, context, or spoke exposed by a reader. type Scope struct { - Name string `json:"name"` - Kinds []string `json:"kinds"` - Reachable bool `json:"reachable"` - ObservedAt time.Time `json:"observed_at,omitempty"` + Name string `json:"name"` + DisplayName string `json:"display_name,omitempty"` + Origin string `json:"origin,omitempty"` + Kinds []string `json:"kinds"` + Reachable bool `json:"reachable"` + ObservedAt time.Time `json:"observed_at,omitempty"` +} + +// Diagnostic is a bounded, safe-to-render discovery warning. It must never contain a credential, +// a kubeconfig payload, or an absolute local path. +type Diagnostic struct { + Source string `json:"source,omitempty"` + Message string `json:"message"` } // Differ computes desired-versus-observed state without mutation. diff --git a/internal/connector/kubeconfig/adapter.go b/internal/connector/kubeconfig/adapter.go index 8cf4e4e..dd2869d 100644 --- a/internal/connector/kubeconfig/adapter.go +++ b/internal/connector/kubeconfig/adapter.go @@ -57,6 +57,8 @@ type tableFactory func(config *rest.Config) (tablePrinter, error) type options struct { loadingRules *clientcmd.ClientConfigLoadingRules + importedConfig *importedConfig + customRules bool probeTimeout time.Duration requestTimeout time.Duration staleAfter time.Duration @@ -81,8 +83,12 @@ func WithLoadingRules(rules *clientcmd.ClientConfigLoadingRules) Option { if rules == nil { return fmt.Errorf("kubeconfig loading rules must not be nil") } + if settings.importedConfig != nil { + return fmt.Errorf("kubeconfig loading rules and directory import are mutually exclusive") + } copyRules := *rules settings.loadingRules = ©Rules + settings.customRules = true return nil } } @@ -90,6 +96,9 @@ func WithLoadingRules(rules *clientcmd.ClientConfigLoadingRules) Option { // WithExplicitPath reads one explicitly selected kubeconfig path. func WithExplicitPath(path string) Option { return func(settings *options) error { + if settings.importedConfig != nil && path != "" { + return fmt.Errorf("explicit kubeconfig path and directory import are mutually exclusive") + } if path != "" { settings.loadingRules.ExplicitPath = path } @@ -97,6 +106,23 @@ func WithExplicitPath(path string) Option { } } +// WithDirectory imports all bounded, regular kubeconfig files beneath one user-selected directory. +// The import is in-memory only: source files and credentials are never copied or persisted. +func WithDirectory(path string) Option { + return func(settings *options) error { + if settings.importedConfig != nil || settings.customRules || settings.loadingRules.ExplicitPath != "" { + return fmt.Errorf("kubeconfig directory import cannot be combined with another explicit kubeconfig source") + } + imported, err := loadDirectory(path) + if err != nil { + return err + } + settings.importedConfig = &imported + settings.loadingRules = &clientcmd.ClientConfigLoadingRules{} + return nil + } +} + // WithProbeTimeout sets the independent reachability deadline for each context. func WithProbeTimeout(timeout time.Duration) Option { return func(settings *options) error { @@ -277,9 +303,9 @@ func (adapter *Adapter) Descriptor() connector.Descriptor { // Discover enumerates every context and probes each independently. func (adapter *Adapter) Discover(ctx context.Context) (connector.Discovery, error) { - rawConfig, err := adapter.settings.loadingRules.Load() + rawConfig, metadata, diagnostics, err := adapter.loadConfig() if err != nil { - return connector.Discovery{}, fmt.Errorf("load kubeconfig: %w", err) + return connector.Discovery{}, err } names := make([]string, 0, len(rawConfig.Contexts)) @@ -291,7 +317,7 @@ func (adapter *Adapter) Discover(ctx context.Context) (connector.Discovery, erro results := make([]contextResult, len(names)) adapter.runBounded(len(names), func(index int) { - results[index] = adapter.probeContext(ctx, *rawConfig, names[index], priorLastSeen[names[index]]) + results[index] = adapter.probeContext(ctx, *rawConfig, names[index], metadata[names[index]], priorLastSeen[names[index]]) }) if err := ctx.Err(); err != nil { return connector.Discovery{}, fmt.Errorf("discover kubeconfig contexts: %w", err) @@ -333,7 +359,22 @@ func (adapter *Adapter) Discover(ctx context.Context) (connector.Discovery, erro adapter.lastSeen = lastSeen adapter.mu.Unlock() - return connector.Discovery{Scopes: cloneScopes(scopes), Unreachable: append([]string(nil), unreachable...)}, nil + return connector.Discovery{ + Scopes: cloneScopes(scopes), Unreachable: append([]string(nil), unreachable...), Diagnostics: cloneDiagnostics(diagnostics), + }, nil +} + +func (adapter *Adapter) loadConfig() (*clientcmdapi.Config, map[string]contextMetadata, []connector.Diagnostic, error) { + if adapter.settings.importedConfig != nil { + return adapter.settings.importedConfig.raw.DeepCopy(), + cloneContextMetadata(adapter.settings.importedConfig.metadata), + cloneDiagnostics(adapter.settings.importedConfig.diagnostics), nil + } + rawConfig, err := adapter.settings.loadingRules.Load() + if err != nil { + return nil, nil, nil, fmt.Errorf("load kubeconfig: %w", err) + } + return rawConfig, map[string]contextMetadata{}, nil, nil } type contextResult struct { @@ -348,12 +389,15 @@ func (adapter *Adapter) probeContext( ctx context.Context, rawConfig clientcmdapi.Config, name string, + metadata contextMetadata, lastSeen time.Time, ) contextResult { scope := connector.Scope{ - Name: name, - Kinds: append([]string(nil), supportedKinds...), - ObservedAt: lastSeen, + Name: name, DisplayName: name, Origin: metadata.origin, + Kinds: append([]string(nil), supportedKinds...), ObservedAt: lastSeen, + } + if metadata.displayName != "" { + scope.DisplayName = metadata.displayName } clientConfig := clientcmd.NewNonInteractiveClientConfig( rawConfig, @@ -493,3 +537,15 @@ func cloneScopes(scopes []connector.Scope) []connector.Scope { } return result } + +func cloneContextMetadata(values map[string]contextMetadata) map[string]contextMetadata { + result := make(map[string]contextMetadata, len(values)) + for key, value := range values { + result[key] = value + } + return result +} + +func cloneDiagnostics(values []connector.Diagnostic) []connector.Diagnostic { + return append([]connector.Diagnostic(nil), values...) +} diff --git a/internal/connector/kubeconfig/directory.go b/internal/connector/kubeconfig/directory.go new file mode 100644 index 0000000..b8895ac --- /dev/null +++ b/internal/connector/kubeconfig/directory.go @@ -0,0 +1,248 @@ +// SPDX-License-Identifier: Apache-2.0 + +package kubeconfig + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + + "github.com/ArdurAI/sith/internal/connector" +) + +const ( + maxImportFiles = 128 + maxImportBytes = 4 << 20 + maxImportDepth = 8 +) + +var errImportLimit = errors.New("kubeconfig directory import limit reached") + +type importedConfig struct { + raw *clientcmdapi.Config + metadata map[string]contextMetadata + diagnostics []connector.Diagnostic +} + +type contextMetadata struct { + displayName string + origin string +} + +// loadDirectory imports independently parsed kubeconfig files without following symlinks. The +// returned config is namespaced by an opaque per-file identifier so duplicate context names cannot +// silently shadow one another. +func loadDirectory(root string) (importedConfig, error) { + root = strings.TrimSpace(root) + if root == "" { + return importedConfig{}, fmt.Errorf("kubeconfig directory is required") + } + absolute, err := filepath.Abs(root) + if err != nil { + return importedConfig{}, fmt.Errorf("resolve kubeconfig directory: %w", err) + } + info, err := os.Lstat(absolute) + if err != nil { + return importedConfig{}, fmt.Errorf("inspect kubeconfig directory: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return importedConfig{}, fmt.Errorf("kubeconfig directory must be a real directory, not a symlink or file") + } + + result := importedConfig{ + raw: clientcmdapi.NewConfig(), + metadata: make(map[string]contextMetadata), + } + candidates := 0 + err = filepath.WalkDir(absolute, func(path string, entry fs.DirEntry, walkErr error) error { + relative, relativeErr := filepath.Rel(absolute, path) + if relativeErr != nil { + return relativeErr + } + relative = filepath.ToSlash(relative) + if relative == "." { + if walkErr != nil { + return fmt.Errorf("read kubeconfig directory: %w", walkErr) + } + return nil + } + if walkErr != nil { + result.diagnostics = append(result.diagnostics, importDiagnostic(relative, "unreadable entry")) + if entry != nil && entry.IsDir() { + return filepath.SkipDir + } + return nil + } + if entry.Type()&os.ModeSymlink != 0 { + result.diagnostics = append(result.diagnostics, importDiagnostic(relative, "symlink input ignored")) + return nil + } + if entry.IsDir() { + if importDepth(relative) > maxImportDepth { + result.diagnostics = append(result.diagnostics, importDiagnostic(relative, "directory depth limit reached")) + return filepath.SkipDir + } + return nil + } + if !entry.Type().IsRegular() { + return nil + } + candidates++ + if candidates > maxImportFiles { + result.diagnostics = append(result.diagnostics, importDiagnostic("", "kubeconfig file limit reached")) + return errImportLimit + } + fileInfo, statErr := entry.Info() + if statErr != nil || fileInfo.Size() > maxImportBytes { + message := "unreadable kubeconfig" + if statErr == nil { + message = "kubeconfig exceeds the import size limit" + } + result.diagnostics = append(result.diagnostics, importDiagnostic(relative, message)) + return nil + } + config, readErr := loadKubeconfigFile(path, fileInfo.Size()) + if readErr != nil { + result.diagnostics = append(result.diagnostics, importDiagnostic(relative, "invalid kubeconfig")) + return nil + } + mergeImportedConfig(result.raw, result.metadata, &result.diagnostics, config, relative) + return nil + }) + if errors.Is(err, errImportLimit) { + err = nil + } + if err != nil { + return importedConfig{}, fmt.Errorf("scan kubeconfig directory: %w", err) + } + sort.Slice(result.diagnostics, func(left, right int) bool { + if result.diagnostics[left].Source == result.diagnostics[right].Source { + return result.diagnostics[left].Message < result.diagnostics[right].Message + } + return result.diagnostics[left].Source < result.diagnostics[right].Source + }) + return result, nil +} + +func loadKubeconfigFile(path string, size int64) (*clientcmdapi.Config, error) { + if size < 0 || size > maxImportBytes { + return nil, fmt.Errorf("kubeconfig exceeds the import size limit") + } + file, err := os.Open(path) // #nosec G304 -- path is discovered beneath a user-selected directory without symlink traversal. + if err != nil { + return nil, err + } + defer func() { _ = file.Close() }() + payload, err := io.ReadAll(io.LimitReader(file, maxImportBytes+1)) + if err != nil { + return nil, err + } + if len(payload) > maxImportBytes { + return nil, fmt.Errorf("kubeconfig exceeds the import size limit") + } + config, err := clientcmd.Load(payload) + if err != nil { + return nil, err + } + setLocationOfOrigin(config, path) + if err := clientcmd.ResolveLocalPaths(config); err != nil { + return nil, err + } + return config, nil +} + +func setLocationOfOrigin(config *clientcmdapi.Config, path string) { + for _, authInfo := range config.AuthInfos { + authInfo.LocationOfOrigin = path + } + for _, cluster := range config.Clusters { + cluster.LocationOfOrigin = path + } + for _, context := range config.Contexts { + context.LocationOfOrigin = path + } +} + +func mergeImportedConfig( + destination *clientcmdapi.Config, + metadata map[string]contextMetadata, + diagnostics *[]connector.Diagnostic, + source *clientcmdapi.Config, + origin string, +) { + identifier := importIdentifier(origin) + clusters := make(map[string]string, len(source.Clusters)) + for _, name := range sortedMapKeys(source.Clusters) { + qualified := identifier + "/cluster/" + name + clusters[name] = qualified + destination.Clusters[qualified] = source.Clusters[name].DeepCopy() + } + users := make(map[string]string, len(source.AuthInfos)) + for _, name := range sortedMapKeys(source.AuthInfos) { + qualified := identifier + "/user/" + name + users[name] = qualified + destination.AuthInfos[qualified] = source.AuthInfos[name].DeepCopy() + } + for _, name := range sortedMapKeys(source.Contexts) { + qualified := identifier + "/context/" + name + context := source.Contexts[name] + if context == nil { + *diagnostics = append(*diagnostics, importDiagnostic(origin, "kubeconfig contains an invalid context")) + continue + } + cluster, clusterExists := clusters[context.Cluster] + if !clusterExists { + *diagnostics = append(*diagnostics, importDiagnostic(origin, "context references an unavailable cluster")) + continue + } + user := "" + if context.AuthInfo != "" { + var userExists bool + user, userExists = users[context.AuthInfo] + if !userExists { + *diagnostics = append(*diagnostics, importDiagnostic(origin, "context references an unavailable user")) + continue + } + } + context = context.DeepCopy() + context.Cluster = cluster + context.AuthInfo = user + destination.Contexts[qualified] = context + metadata[qualified] = contextMetadata{displayName: name, origin: origin} + } +} + +func importIdentifier(origin string) string { + digest := sha256.Sum256([]byte(origin)) + return "import-" + hex.EncodeToString(digest[:8]) +} + +func sortedMapKeys[T any](values map[string]*T) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func importDepth(relative string) int { + if relative == "" || relative == "." { + return 0 + } + return len(strings.Split(relative, "/")) +} + +func importDiagnostic(source, message string) connector.Diagnostic { + return connector.Diagnostic{Source: source, Message: message} +} diff --git a/internal/connector/kubeconfig/directory_test.go b/internal/connector/kubeconfig/directory_test.go new file mode 100644 index 0000000..ef8d51e --- /dev/null +++ b/internal/connector/kubeconfig/directory_test.go @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: Apache-2.0 + +package kubeconfig + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "slices" + "strings" + "sync" + "testing" + + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" +) + +func TestDirectoryImportPreservesDuplicateContextNames(t *testing.T) { + t.Parallel() + directory := t.TempDir() + writeDirectoryConfig(t, filepath.Join(directory, "alpha.yaml"), "shared", "https://alpha.invalid") + writeDirectoryConfig(t, filepath.Join(directory, "nested", "beta.yaml"), "shared", "https://beta.invalid") + + var mu sync.Mutex + probed := make([]string, 0, 2) + adapter, err := New( + WithDirectory(directory), + withProbe(func(_ context.Context, config *rest.Config) error { + mu.Lock() + defer mu.Unlock() + probed = append(probed, config.Host) + return nil + }), + ) + if err != nil { + t.Fatalf("New() error = %v", err) + } + discovery, err := adapter.Discover(t.Context()) + if err != nil { + t.Fatalf("Discover() error = %v", err) + } + if len(discovery.Scopes) != 2 || len(discovery.Unreachable) != 0 || len(discovery.Diagnostics) != 0 { + t.Fatalf("Discovery = %#v, want two clean imported scopes", discovery) + } + slices.Sort(probed) + if !slices.Equal(probed, []string{"https://alpha.invalid", "https://beta.invalid"}) { + t.Fatalf("probed hosts = %v", probed) + } + origins := make([]string, 0, len(discovery.Scopes)) + names := make([]string, 0, len(discovery.Scopes)) + for _, scope := range discovery.Scopes { + if scope.DisplayName != "shared" || !scope.Reachable || strings.Contains(scope.Origin, directory) { + t.Fatalf("scope = %#v, want reachable relative-name metadata", scope) + } + origins = append(origins, scope.Origin) + names = append(names, scope.Name) + } + slices.Sort(origins) + if !slices.Equal(origins, []string{"alpha.yaml", "nested/beta.yaml"}) || names[0] == names[1] { + t.Fatalf("origins/names = %v/%v, want distinct imported contexts", origins, names) + } +} + +func TestDirectoryImportSurfacesSafeDiagnostics(t *testing.T) { + t.Parallel() + directory := t.TempDir() + writeDirectoryConfig(t, filepath.Join(directory, "valid.yaml"), "alpha", "https://alpha.invalid") + secret := "not-a-real-secret-but-must-not-leak" + if err := os.WriteFile(filepath.Join(directory, "broken.yaml"), []byte("clusters: ["+secret), 0o600); err != nil { + t.Fatal(err) + } + outside := filepath.Join(t.TempDir(), "outside.yaml") + writeDirectoryConfig(t, outside, "outside", "https://outside.invalid") + if err := os.Symlink(outside, filepath.Join(directory, "linked.yaml")); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(directory, "large.yaml"), make([]byte, maxImportBytes+1), 0o600); err != nil { + t.Fatal(err) + } + + adapter, err := New(WithDirectory(directory), withProbe(func(_ context.Context, _ *rest.Config) error { return nil })) + if err != nil { + t.Fatalf("New() error = %v", err) + } + discovery, err := adapter.Discover(t.Context()) + if err != nil { + t.Fatalf("Discover() error = %v", err) + } + if len(discovery.Scopes) != 1 || len(discovery.Diagnostics) != 3 { + t.Fatalf("Discovery = %#v, want one valid scope and three diagnostics", discovery) + } + payload, err := json.Marshal(discovery) + if err != nil { + t.Fatal(err) + } + for _, forbidden := range []string{directory, outside, secret} { + if strings.Contains(string(payload), forbidden) { + t.Fatalf("safe discovery metadata leaked %q: %s", forbidden, payload) + } + } + for _, source := range []string{"broken.yaml", "large.yaml", "linked.yaml"} { + if !strings.Contains(string(payload), source) { + t.Errorf("diagnostics = %s, want source %q", payload, source) + } + } +} + +func TestDirectoryImportRejectsUnsafeRoots(t *testing.T) { + t.Parallel() + regularFile := filepath.Join(t.TempDir(), "not-a-directory") + if err := os.WriteFile(regularFile, []byte("apiVersion: v1\n"), 0o600); err != nil { + t.Fatal(err) + } + linkedRoot := filepath.Join(t.TempDir(), "linked-root") + if err := os.Symlink(t.TempDir(), linkedRoot); err != nil { + t.Fatal(err) + } + for _, path := range []string{"", regularFile, linkedRoot} { + if _, err := New(WithDirectory(path)); err == nil { + t.Errorf("New(WithDirectory(%q)) error = nil, want unsafe root refusal", path) + } + } +} + +func TestDirectoryImportConflictsWithExplicitSource(t *testing.T) { + t.Parallel() + directory := t.TempDir() + if _, err := New(WithExplicitPath(filepath.Join(directory, "config")), WithDirectory(directory)); err == nil { + t.Fatal("New() error = nil, want explicit-path/directory conflict") + } + if _, err := New(WithLoadingRules(&clientcmd.ClientConfigLoadingRules{}), WithDirectory(directory)); err == nil { + t.Fatal("New() error = nil, want custom-rules/directory conflict") + } +} + +func TestStandardKubeconfigUsesContextNameAsDisplayLabel(t *testing.T) { + t.Parallel() + adapter, err := New( + WithLoadingRules(testLoadingRules(t, testConfig("alpha"))), + withProbe(func(_ context.Context, _ *rest.Config) error { return nil }), + ) + if err != nil { + t.Fatal(err) + } + discovery, err := adapter.Discover(t.Context()) + if err != nil || len(discovery.Scopes) != 1 { + t.Fatalf("Discover() = %#v, %v", discovery, err) + } + if scope := discovery.Scopes[0]; scope.Name != "alpha" || scope.DisplayName != "alpha" || scope.Origin != "" { + t.Fatalf("standard scope = %#v", scope) + } +} + +func TestDirectoryImportSkipsBrokenContextReferences(t *testing.T) { + t.Parallel() + directory := t.TempDir() + config := clientcmdapi.NewConfig() + config.Clusters["alpha"] = &clientcmdapi.Cluster{Server: "https://alpha.invalid"} + config.AuthInfos["alpha"] = &clientcmdapi.AuthInfo{} + config.Contexts["alpha"] = &clientcmdapi.Context{Cluster: "alpha", AuthInfo: "alpha"} + config.Contexts["missing-cluster"] = &clientcmdapi.Context{Cluster: "gone", AuthInfo: "alpha"} + config.Contexts["missing-user"] = &clientcmdapi.Context{Cluster: "alpha", AuthInfo: "gone"} + path := filepath.Join(directory, "mixed.yaml") + if err := clientcmd.WriteToFile(*config, path); err != nil { + t.Fatal(err) + } + probes := 0 + adapter, err := New(WithDirectory(directory), withProbe(func(_ context.Context, _ *rest.Config) error { + probes++ + return nil + })) + if err != nil { + t.Fatal(err) + } + discovery, err := adapter.Discover(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(discovery.Scopes) != 1 || probes != 1 { + t.Fatalf("discovery/probes = %#v/%d, want one valid context only", discovery, probes) + } + messages := make([]string, 0, len(discovery.Diagnostics)) + for _, diagnostic := range discovery.Diagnostics { + if diagnostic.Source != "mixed.yaml" { + t.Fatalf("diagnostic = %#v, want relative source", diagnostic) + } + messages = append(messages, diagnostic.Message) + } + slices.Sort(messages) + if !slices.Equal(messages, []string{"context references an unavailable cluster", "context references an unavailable user"}) { + t.Fatalf("diagnostics = %v", messages) + } +} + +func writeDirectoryConfig(t *testing.T, path, contextName, host string) { + t.Helper() + config := clientcmdapi.NewConfig() + config.Clusters[contextName] = &clientcmdapi.Cluster{Server: host} + config.AuthInfos[contextName] = &clientcmdapi.AuthInfo{} + config.Contexts[contextName] = &clientcmdapi.Context{Cluster: contextName, AuthInfo: contextName} + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := clientcmd.WriteToFile(*config, path); err != nil { + t.Fatalf("write kubeconfig %s: %v", path, err) + } +} diff --git a/internal/connector/kubeconfig/local_objects.go b/internal/connector/kubeconfig/local_objects.go index 39f534b..5c03b68 100644 --- a/internal/connector/kubeconfig/local_objects.go +++ b/internal/connector/kubeconfig/local_objects.go @@ -232,15 +232,15 @@ func (adapter *Adapter) ensureLocalContext(ctx context.Context, name string) err if known { return nil } - rawConfig, err := adapter.settings.loadingRules.Load() + rawConfig, metadata, _, err := adapter.loadConfig() if err != nil { - return fmt.Errorf("load kubeconfig: %w", err) + return err } if _, exists := rawConfig.Contexts[name]; !exists { return fmt.Errorf("%w: %s", ErrUnknownScope, name) } lastSeen := adapter.lastSeenSnapshot()[name] - result := adapter.probeContext(ctx, *rawConfig, name, lastSeen) + result := adapter.probeContext(ctx, *rawConfig, name, metadata[name], lastSeen) adapter.mu.Lock() adapter.scopes[name] = cloneScope(result.scope) if result.scope.Reachable { diff --git a/internal/fleetcache/store.go b/internal/fleetcache/store.go index 8e1f397..8a3ab74 100644 --- a/internal/fleetcache/store.go +++ b/internal/fleetcache/store.go @@ -31,15 +31,16 @@ const ( // Snapshot is an immutable cache-only answer for one render interaction. type Snapshot struct { - Version uint64 `json:"version"` - State State `json:"state"` - Syncing bool `json:"syncing"` - Paused bool `json:"paused"` - Records []Record `json:"records"` - Coverage fleet.Coverage `json:"coverage"` - UpdatedAt time.Time `json:"updated_at,omitempty"` - LastError string `json:"last_error,omitempty"` - Scopes []connector.Scope `json:"scopes"` + Version uint64 `json:"version"` + State State `json:"state"` + Syncing bool `json:"syncing"` + Paused bool `json:"paused"` + Records []Record `json:"records"` + Coverage fleet.Coverage `json:"coverage"` + UpdatedAt time.Time `json:"updated_at,omitempty"` + LastError string `json:"last_error,omitempty"` + Scopes []connector.Scope `json:"scopes"` + Diagnostics []connector.Diagnostic `json:"diagnostics,omitempty"` } // Store owns normalized last-known fleet state and never performs network I/O. @@ -50,6 +51,7 @@ type Store struct { coverage map[string]fleet.Coverage aliases map[string]string scopes map[string]connector.Scope + diagnostics map[string][]connector.Diagnostic scopeWorkspaces map[string]map[string]bool warmed map[string]bool expected map[string]bool @@ -74,6 +76,7 @@ func newStore(now func() time.Time, freshFor time.Duration) *Store { coverage: make(map[string]fleet.Coverage), aliases: make(map[string]string), scopes: make(map[string]connector.Scope), + diagnostics: make(map[string][]connector.Diagnostic), scopeWorkspaces: make(map[string]map[string]bool), warmed: make(map[string]bool), expected: make(map[string]bool), @@ -129,6 +132,7 @@ func (store *Store) SetDiscovery(workspace string, discovery connector.Discovery store.scopes[name] = scope store.markScopeWorkspaceLocked(workspace, name) } + store.diagnostics[workspace] = cloneDiagnostics(discovery.Diagnostics) store.notifyLocked() } @@ -336,7 +340,7 @@ func (store *Store) Query(workspace string, query Query) Snapshot { store.mu.RLock() defer store.mu.RUnlock() if strings.TrimSpace(workspace) == "" { - return Snapshot{State: StateCold, Records: []Record{}, Scopes: []connector.Scope{}} + return Snapshot{State: StateCold, Records: []Record{}, Scopes: []connector.Scope{}, Diagnostics: []connector.Diagnostic{}} } now := store.now().UTC() records := make([]Record, 0) @@ -382,15 +386,16 @@ func (store *Store) Query(workspace string, query Query) Snapshot { } pending := selectedKind != "" && !store.warmed[selectedKind] return Snapshot{ - Version: store.version, - State: store.stateLocked(coverage, store.recordCountLocked(workspace), pending), - Syncing: store.syncing, - Paused: store.paused, - Records: records, - Coverage: coverage, - UpdatedAt: store.updatedAt, - LastError: store.lastError, - Scopes: store.scopesLocked(workspace, query.Scopes), + Version: store.version, + State: store.stateLocked(coverage, store.recordCountLocked(workspace), pending), + Syncing: store.syncing, + Paused: store.paused, + Records: records, + Coverage: coverage, + UpdatedAt: store.updatedAt, + LastError: store.lastError, + Scopes: store.scopesLocked(workspace, query.Scopes), + Diagnostics: cloneDiagnostics(store.diagnostics[workspace]), } } @@ -635,3 +640,7 @@ func cloneScope(scope connector.Scope) connector.Scope { scope.Kinds = append([]string(nil), scope.Kinds...) return scope } + +func cloneDiagnostics(values []connector.Diagnostic) []connector.Diagnostic { + return append([]connector.Diagnostic(nil), values...) +} diff --git a/internal/fleetcache/store_test.go b/internal/fleetcache/store_test.go index b11320a..24dbc32 100644 --- a/internal/fleetcache/store_test.go +++ b/internal/fleetcache/store_test.go @@ -438,6 +438,25 @@ func TestParseCorrelationSupportsHealthAndImageForms(t *testing.T) { } } +func TestSnapshotRetainsSafeDiscoveryMetadata(t *testing.T) { + t.Parallel() + store := New() + now := time.Now().UTC() + store.SetDiscovery(fleet.LocalWorkspace, connector.Discovery{ + Scopes: []connector.Scope{{ + Name: "import-123/context/prod", DisplayName: "prod", Origin: "team-a.yaml", Reachable: true, ObservedAt: now, + }}, + Diagnostics: []connector.Diagnostic{{Source: "bad.yaml", Message: "invalid kubeconfig"}}, + }) + snapshot := store.Query(fleet.LocalWorkspace, Query{}) + if len(snapshot.Scopes) != 1 || snapshot.Scopes[0].DisplayName != "prod" || snapshot.Scopes[0].Origin != "team-a.yaml" { + t.Fatalf("scopes = %#v", snapshot.Scopes) + } + if !slices.Equal(snapshot.Diagnostics, []connector.Diagnostic{{Source: "bad.yaml", Message: "invalid kubeconfig"}}) { + t.Fatalf("diagnostics = %#v", snapshot.Diagnostics) + } +} + func podFact(t *testing.T, cluster, name, status, image string, observed time.Time) fleet.Fact { t.Helper() object := map[string]any{ diff --git a/internal/webui/assets/app.css b/internal/webui/assets/app.css index 71e399b..c6e0c1b 100644 --- a/internal/webui/assets/app.css +++ b/internal/webui/assets/app.css @@ -213,6 +213,8 @@ h2 { margin-bottom: 0; font: 650 21px/1.15 var(--display); letter-spacing: -.02e .context-node[aria-current="true"] { color: var(--cobalt); } .context-node strong { display: block; overflow: hidden; text-overflow: ellipsis; font-size: 12px; } .context-node small { display: block; margin-top: 2px; color: var(--muted); font: 10px/1.2 var(--mono); } +.context-source { margin-top: 6px; border-top: 1px solid var(--line); } +.context-diagnostic { margin: 8px 4px; color: var(--amber); font: 10px/1.35 var(--mono); } .rail-note { color: var(--muted); font-size: 11px; line-height: 1.5; } .fleet-board { min-width: 0; padding: 22px; } diff --git a/internal/webui/assets/app.js b/internal/webui/assets/app.js index 0d3742b..22199ac 100644 --- a/internal/webui/assets/app.js +++ b/internal/webui/assets/app.js @@ -90,7 +90,7 @@ function renderSnapshot() { dom["board-heading"].textContent = state.correlate || state.query ? "Fleet results" : `${state.lens}s`; dom["board-kicker"].textContent = state.correlate ? "Correlation answer" : state.query ? "Filtered cache" : "Aggregated lens"; dom["result-count"].textContent = `${snapshot.records.length} cached row${snapshot.records.length === 1 ? "" : "s"}`; - renderContexts(snapshot.scopes || [], coverage); + renderContexts(snapshot.scopes || [], coverage, snapshot.diagnostics || []); renderRows(snapshot.records || []); if (state.selected) { const identity = recordIdentity(state.selected); @@ -99,7 +99,7 @@ function renderSnapshot() { renderInspector(); } -function renderContexts(scopes, coverage) { +function renderContexts(scopes, coverage, diagnostics) { const entries = []; const all = node("button", "context-node"); all.type = "button"; @@ -108,17 +108,38 @@ function renderContexts(scopes, coverage) { all.append(contextLabel("All contexts", compactCoverage(coverage))); all.addEventListener("click", () => { state.scope = ""; loadSnapshot(); }); entries.push(all); + const grouped = new Map(); for (const scope of scopes) { + const origin = scope.origin || ""; + if (!grouped.has(origin)) grouped.set(origin, []); + grouped.get(origin).push(scope); + } + for (const [origin, members] of grouped) { + if (origin) { + const sourceScopes = members.map((scope) => scope.name).join(","); + const source = node("button", "context-node context-source"); + source.type = "button"; + source.setAttribute("aria-current", String(state.scope === sourceScopes)); + source.append(contextLabel(origin, `${members.length} imported context${members.length === 1 ? "" : "s"}`)); + source.addEventListener("click", () => { state.scope = state.scope === sourceScopes ? "" : sourceScopes; state.selected = null; loadSnapshot(); }); + entries.push(source); + } + for (const scope of members) { const button = node("button", "context-node"); button.type = "button"; const isDown = !scope.reachable; const isStale = (coverage.stale || []).includes(scope.name); button.dataset.state = isDown ? "down" : isStale ? "stale" : "fresh"; button.setAttribute("aria-current", String(state.scope === scope.name)); - button.append(contextLabel(scope.name, `${isDown ? "unreachable" : isStale ? "stale" : "reachable"} · ${ageLabel(scope.observed_at)}`)); + button.append(contextLabel(scope.display_name || scope.name, `${isDown ? "unreachable" : isStale ? "stale" : "reachable"} · ${ageLabel(scope.observed_at)}`)); button.addEventListener("click", () => { state.scope = state.scope === scope.name ? "" : scope.name; loadSnapshot(); }); entries.push(button); } + } + for (const diagnostic of diagnostics) { + const source = diagnostic.source ? `${diagnostic.source}: ` : ""; + entries.push(node("div", "context-diagnostic", `Import warning — ${source}${diagnostic.message}`)); + } replaceChildren(dom["context-list"], entries); } diff --git a/internal/webui/server_test.go b/internal/webui/server_test.go index 73ac730..3427e42 100644 --- a/internal/webui/server_test.go +++ b/internal/webui/server_test.go @@ -122,6 +122,27 @@ func TestSnapshotReadsCacheOnlyAndRefreshIsExplicit(t *testing.T) { } } +func TestSnapshotExposesOnlySafeImportMetadata(t *testing.T) { + t.Parallel() + store := populatedWebStore(t) + store.SetDiscovery(fleet.LocalWorkspace, connector.Discovery{ + Scopes: []connector.Scope{{ + Name: "import-123/context/prod", DisplayName: "prod", Origin: "team-a.yaml", Reachable: true, ObservedAt: time.Now().UTC(), + }}, + Diagnostics: []connector.Diagnostic{{Source: "broken.yaml", Message: "invalid kubeconfig"}}, + }) + application, err := New(t.Context(), store, &webSyncer{}, &webLocalClient{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = application.Close() }) + response := serve(testHandler(t, application), http.MethodGet, "/api/v1/snapshot", application.token, nil) + if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "team-a.yaml") || + !strings.Contains(response.Body.String(), "broken.yaml") || strings.Contains(response.Body.String(), "/Users/") { + t.Fatalf("snapshot status/body = %d/%s", response.Code, response.Body.String()) + } +} + func TestRefreshRequestsAreSingleFlight(t *testing.T) { t.Parallel() syncer := &blockingWebSyncer{started: make(chan struct{}), release: make(chan struct{})} diff --git a/sessions/2026-07-14-f11-kubeconfig-directory-import.md b/sessions/2026-07-14-f11-kubeconfig-directory-import.md new file mode 100644 index 0000000..0fc10ae --- /dev/null +++ b/sessions/2026-07-14-f11-kubeconfig-directory-import.md @@ -0,0 +1,20 @@ +# Session — 2026-07-14 — F11 kubeconfig directory import + +**Builder:** Gnani Rahul · **Model/effort:** Codex / autonomous implementation · **Branch:** `gnanirahulnutakki/feat/f11-kubeconfig-directory-import` +**Slice(s):** F11.7 / #162 · **Status:** implementation-committed + +--- + +[G] Goal: Let the loopback local fleet IDE import a selected directory of kubeconfig files and group its contexts without compromising the no-account, no-telemetry, credential-local posture. + +[S] Scope: `sith ui --kubeconfig-dir` only; bounded in-memory directory import, source-grouped UI contexts, safe diagnostics, unit/race tests, and the existing real two-kind UI gate. Out: a native desktop wrapper, browser filesystem picker, kubeconfig writes, lazy source activation, and claims of complete Lens feature parity. + +[A] Action: Added a safe directory loader that rejects unsafe roots, ignores symlinks, bounds the walk, parses files independently with client-go-compatible configuration semantics, namespaces imported context/cluster/user identities deterministically, and retains only relative source labels plus generic diagnostics in the cache/UI model. Wired the flag into `sith ui`; source selection filters the existing cache and local operations retain their direct user-identity Kubernetes path. + +[T] Test: Targeted race suites passed after every review correction. Final exact-source gates passed: `make ci` (format, lint, vet, govulncheck, full race/egress/policy suite, binary build); `make e2e-isolation` (forced PostgreSQL RLS and 50,000x cross-workspace fuzz); `KIND=/Volumes/EXTENDED/MacData/tools/bin/kind make e2e-kind` (real two-kind fan-out plus OCI contract in 160.782 seconds); and `make release-check` (two reproducible four-platform archive/SBOM passes plus formula generation). + +[C] Checkpoint #1: signed/DCO/GSTACK F11.7 implementation checkpoint. CodeRabbit review completed with two resolved major and three resolved minor findings; one generic-cache sanitization suggestion was declined because it would mask a connector-contract violation already prevented and tested at the directory-import boundary. All final gates and cleanup/security queue checks are green; the immutable implementation SHA is recorded by the follow-up journal checkpoint before PR creation. + +--- + +**Session close:** implementation committed; journal checkpoint follows · **Open questions touched:** none; the existing aggregation-first startup model remains the safe default, so selection filters an already independently probed source rather than delaying fleet discovery. diff --git a/tests/e2e/kind_fanout_test.go b/tests/e2e/kind_fanout_test.go index 868f1f5..b38333c 100644 --- a/tests/e2e/kind_fanout_test.go +++ b/tests/e2e/kind_fanout_test.go @@ -132,6 +132,8 @@ func TestKindFleetFanout(t *testing.T) { runCommand(ctx, t, root, "go", "build", "-trimpath", "-o", binary, "./cmd/sith") exerciseLocalOperations(ctx, t, binary, kubeconfigPath, clusterNames) exerciseWebUI(ctx, t, binary, kubeconfigPath, clusterNames) + kubeconfigDirectory := splitKindKubeconfigDirectory(t, kubeconfigPath, clusterNames) + exerciseWebUIDirectoryImport(ctx, t, binary, kubeconfigDirectory, clusterNames) exerciseMCP(ctx, t, binary, kubeconfigPath, clusterNames) command := exec.CommandContext(ctx, binary, "clusters", "--output", "json") command.Env = append(os.Environ(), "KUBECONFIG="+kubeconfigPath, "XDG_CONFIG_HOME="+t.TempDir()) @@ -743,6 +745,35 @@ func mergedKindKubeconfig(ctx context.Context, t *testing.T, kindBinary string, return path } +func splitKindKubeconfigDirectory(t *testing.T, mergedPath string, clusters []string) string { + t.Helper() + merged, err := clientcmd.LoadFromFile(mergedPath) + if err != nil { + t.Fatalf("load merged kind kubeconfig: %v", err) + } + directory := t.TempDir() + for index, cluster := range clusters { + contextName := "kind-" + cluster + contextConfig, exists := merged.Contexts[contextName] + if !exists { + t.Fatalf("merged kubeconfig missing context %q", contextName) + } + config := clientcmdapi.NewConfig() + config.Clusters[contextConfig.Cluster] = merged.Clusters[contextConfig.Cluster].DeepCopy() + config.AuthInfos[contextConfig.AuthInfo] = merged.AuthInfos[contextConfig.AuthInfo].DeepCopy() + config.Contexts[contextName] = contextConfig.DeepCopy() + config.CurrentContext = contextName + filename := filepath.Join(directory, "first.yaml") + if index == 1 { + filename = filepath.Join(directory, "nested", "second.yaml") + } + if err := clientcmd.WriteToFile(*config, filename); err != nil { + t.Fatalf("write imported kind kubeconfig %q: %v", filename, err) + } + } + return directory +} + func mergeConfigMaps(destination, source *clientcmdapi.Config) { for name, cluster := range source.Clusters { destination.Clusters[name] = cluster diff --git a/tests/e2e/kind_web_ui_test.go b/tests/e2e/kind_web_ui_test.go index 0ced2a7..bbed74f 100644 --- a/tests/e2e/kind_web_ui_test.go +++ b/tests/e2e/kind_web_ui_test.go @@ -175,6 +175,81 @@ func exerciseWebUI( process.stop(t) } +func exerciseWebUIDirectoryImport( + ctx context.Context, + t *testing.T, + binary, kubeconfigDirectory string, + clusters []string, +) { + t.Helper() + process, origin := startWebUIFromDirectory(ctx, t, binary, kubeconfigDirectory) + t.Cleanup(func() { process.stop(t) }) + client := &http.Client{Timeout: 20 * time.Second} + index := webUIRequest(ctx, t, client, http.MethodGet, origin, "", "", nil) + match := webUITokenPattern.FindSubmatch(index.Body) + if index.StatusCode != http.StatusOK || len(match) != 2 { + process.stop(t) + t.Fatalf("directory-import web UI index status/body = %d/%q", index.StatusCode, index.Body) + } + token := string(match[1]) + deadline := time.NewTimer(45 * time.Second) + defer deadline.Stop() + var snapshot fleetcache.Snapshot + for { + response := webUIRequest(ctx, t, client, http.MethodGet, origin, "/api/v1/snapshot?kind=Pod", token, nil) + decodeWebUIJSON(t, response, http.StatusOK, &snapshot) + if snapshot.Coverage.Requested == 2 && snapshot.Coverage.Reachable == 2 && len(snapshot.Scopes) == 2 { + break + } + select { + case <-ctx.Done(): + t.Fatalf("wait for directory-import UI: %v", ctx.Err()) + case <-deadline.C: + t.Fatalf("directory-import UI did not hydrate both contexts: %#v", snapshot) + case <-time.After(250 * time.Millisecond): + } + } + if strings.Contains(mustMarshalWebUISnapshot(t, snapshot), kubeconfigDirectory) { + t.Fatalf("directory-import snapshot exposed absolute directory: %#v", snapshot) + } + byOrigin := make(map[string]string, len(snapshot.Scopes)) + for _, scope := range snapshot.Scopes { + if scope.DisplayName == "" || scope.Origin == "" || scope.Name == scope.DisplayName { + t.Fatalf("directory-import scope metadata = %#v", scope) + } + byOrigin[scope.Origin] = scope.Name + } + if len(byOrigin) != 2 || byOrigin["first.yaml"] == "" || byOrigin["nested/second.yaml"] == "" { + t.Fatalf("directory-import source groups = %#v", byOrigin) + } + selected := byOrigin["first.yaml"] + filtered := webUIRequest( + ctx, t, client, http.MethodGet, origin, + "/api/v1/snapshot?kind=Pod&scopes="+url.QueryEscape(selected), token, nil, + ) + var oneSource fleetcache.Snapshot + decodeWebUIJSON(t, filtered, http.StatusOK, &oneSource) + if len(oneSource.Scopes) != 1 || oneSource.Scopes[0].Name != selected || oneSource.Coverage.Requested != 1 { + t.Fatalf("directory-import source selection = %#v", oneSource) + } + for _, record := range oneSource.Records { + if record.Cluster != selected { + t.Fatalf("directory-import selection returned another source: %#v", oneSource.Records) + } + } + objectPath := webUITargetPath("/api/v1/object", localops.Target{ + Context: selected, Namespace: "default", Kind: "Pod", Name: "sith-local-ops", + }) + object := webUIRequest(ctx, t, client, http.MethodGet, origin, objectPath, token, nil) + var viewed webUIObject + decodeWebUIJSON(t, object, http.StatusOK, &viewed) + if viewed.Target.Context != selected || !strings.Contains(viewed.YAML, "sith-local-ops") || + !strings.Contains(viewed.YAML, "kind-"+clusters[0]) { + t.Fatalf("directory-import object read = %#v/%q", viewed.Target, viewed.YAML) + } + process.stop(t) +} + func exerciseWebUIEdit( ctx context.Context, t *testing.T, @@ -310,6 +385,60 @@ func startWebUI(ctx context.Context, t *testing.T, binary, kubeconfigPath string return nil, "" } +func startWebUIFromDirectory(ctx context.Context, t *testing.T, binary, kubeconfigDirectory string) (*webUIProcess, string) { + t.Helper() + command := exec.Command(binary, "ui", "--no-open", "--address", "127.0.0.1", "--port", "0", "--kubeconfig-dir", kubeconfigDirectory) + command.Env = append(os.Environ(), "XDG_CONFIG_HOME="+filepath.Join(kubeconfigDirectory, "config-home-web")) + stdout, err := command.StdoutPipe() + if err != nil { + t.Fatalf("directory-import web UI stdout: %v", err) + } + process := &webUIProcess{command: command, stderr: &bytes.Buffer{}} + command.Stderr = process.stderr + if err := command.Start(); err != nil { + t.Fatalf("start directory-import web UI: %v", err) + } + lines := make(chan string, 1) + go func() { + scanner := bufio.NewScanner(stdout) + if scanner.Scan() { + lines <- scanner.Text() + } + close(lines) + }() + timer := time.NewTimer(30 * time.Second) + defer timer.Stop() + select { + case line, open := <-lines: + if !open { + process.stop(t) + t.Fatalf("directory-import web UI exited before reporting its address: %s", process.stderr.String()) + } + match := webUIAddressPattern.FindStringSubmatch(line) + if len(match) != 2 { + process.stop(t) + t.Fatalf("directory-import web UI address line = %q", line) + } + return process, match[1] + case <-timer.C: + process.stop(t) + t.Fatalf("directory-import web UI did not report its address: %s", process.stderr.String()) + case <-ctx.Done(): + process.stop(t) + t.Fatalf("directory-import web UI context ended before startup: %v", ctx.Err()) + } + return nil, "" +} + +func mustMarshalWebUISnapshot(t *testing.T, snapshot fleetcache.Snapshot) string { + t.Helper() + payload, err := json.Marshal(snapshot) + if err != nil { + t.Fatal(err) + } + return string(payload) +} + func (process *webUIProcess) stop(t *testing.T) { t.Helper() process.once.Do(func() { From c47f2928df70f94c0795da758324581af839ed98 Mon Sep 17 00:00:00 2001 From: Gnani Rahul Date: Tue, 14 Jul 2026 17:32:55 -0500 Subject: [PATCH 2/7] docs(session): record F11 directory import checkpoint GSTACK-Checkpoint: 2026-07-14/f11-kubeconfig-directory-import#2 Signed-off-by: Gnani Rahul --- sessions/2026-07-14-f11-kubeconfig-directory-import.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/sessions/2026-07-14-f11-kubeconfig-directory-import.md b/sessions/2026-07-14-f11-kubeconfig-directory-import.md index 0fc10ae..584a30c 100644 --- a/sessions/2026-07-14-f11-kubeconfig-directory-import.md +++ b/sessions/2026-07-14-f11-kubeconfig-directory-import.md @@ -1,7 +1,7 @@ # Session — 2026-07-14 — F11 kubeconfig directory import **Builder:** Gnani Rahul · **Model/effort:** Codex / autonomous implementation · **Branch:** `gnanirahulnutakki/feat/f11-kubeconfig-directory-import` -**Slice(s):** F11.7 / #162 · **Status:** implementation-committed +**Slice(s):** F11.7 / #162 · **Status:** ready-for-review --- @@ -15,6 +15,8 @@ [C] Checkpoint #1: signed/DCO/GSTACK F11.7 implementation checkpoint. CodeRabbit review completed with two resolved major and three resolved minor findings; one generic-cache sanitization suggestion was declined because it would mask a connector-contract violation already prevented and tested at the directory-import boundary. All final gates and cleanup/security queue checks are green; the immutable implementation SHA is recorded by the follow-up journal checkpoint before PR creation. +[C] Checkpoint #2: `43f0118010c4c0f7ded80d05e2490b87eb50e24d` — signed/DCO/GSTACK implementation commit verified locally with GitHub-recognized SSH identity. Next: push PR #162 into `dev`, require hosted CI and CodeRabbit review, merge, then verify exact post-merge CI and queues. + --- -**Session close:** implementation committed; journal checkpoint follows · **Open questions touched:** none; the existing aggregation-first startup model remains the safe default, so selection filters an already independently probed source rather than delaying fleet discovery. +**Session close:** ready for review · **Open questions touched:** none; the existing aggregation-first startup model remains the safe default, so selection filters an already independently probed source rather than delaying fleet discovery. From d6a3ee630af83f3f611d9453fffa0509d04ab537 Mon Sep 17 00:00:00 2001 From: Gnani Rahul Date: Tue, 14 Jul 2026 18:04:19 -0500 Subject: [PATCH 3/7] fix(f11): harden kubeconfig directory review gaps Cap all filesystem traversal entries, redact root traversal errors, and prove multi-context source filtering in the real cluster suite. GSTACK-Checkpoint: 2026-07-14/f11-directory-import-review#1 Signed-off-by: Gnani Rahul --- README.md | 3 +- internal/connector/kubeconfig/directory.go | 22 +++-- .../connector/kubeconfig/directory_test.go | 32 ++++++- ...07-14-f11-directory-import-review-fixes.md | 22 +++++ tests/e2e/kind_fanout_test.go | 34 +++++--- tests/e2e/kind_web_ui_test.go | 85 +++++++------------ 6 files changed, 123 insertions(+), 75 deletions(-) create mode 100644 sessions/2026-07-14-f11-directory-import-review-fixes.md diff --git a/README.md b/README.md index 415c312..2439edc 100644 --- a/README.md +++ b/README.md @@ -341,7 +341,8 @@ local listener starts. Invalid, oversized, unreadable, or symlinked entries bene are skipped with safe warnings that do not expose kubeconfig contents or an absolute local path. Each imported source is labeled by its relative filename; contexts with the same name remain isolated, and selecting a source in the context rail filters to its contexts. The import is limited -to 128 files, 4 MiB per file, and eight nested directory levels. The browser renders the same cache, lenses, ordering, +to 128 traversed filesystem entries (including ignored symlinks and directories), 4 MiB per regular +kubeconfig file, and eight nested directory levels. The browser renders the same cache, lenses, ordering, coverage, search/correlation grammar, and per-resource operations as the CLI/TUI. Its local HTTP boundary requires an exact Host/Origin and a per-process capability header, uses a restrictive Content Security Policy, and loads no remote assets. YAML apply additionally requires a short-lived, diff --git a/internal/connector/kubeconfig/directory.go b/internal/connector/kubeconfig/directory.go index b8895ac..631a86d 100644 --- a/internal/connector/kubeconfig/directory.go +++ b/internal/connector/kubeconfig/directory.go @@ -49,11 +49,11 @@ func loadDirectory(root string) (importedConfig, error) { } absolute, err := filepath.Abs(root) if err != nil { - return importedConfig{}, fmt.Errorf("resolve kubeconfig directory: %w", err) + return importedConfig{}, errors.New("cannot resolve kubeconfig directory") } info, err := os.Lstat(absolute) if err != nil { - return importedConfig{}, fmt.Errorf("inspect kubeconfig directory: %w", err) + return importedConfig{}, errors.New("cannot inspect kubeconfig directory") } if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { return importedConfig{}, fmt.Errorf("kubeconfig directory must be a real directory, not a symlink or file") @@ -64,18 +64,24 @@ func loadDirectory(root string) (importedConfig, error) { metadata: make(map[string]contextMetadata), } candidates := 0 + entries := 0 err = filepath.WalkDir(absolute, func(path string, entry fs.DirEntry, walkErr error) error { relative, relativeErr := filepath.Rel(absolute, path) if relativeErr != nil { - return relativeErr + return errors.New("cannot scan kubeconfig directory") } relative = filepath.ToSlash(relative) if relative == "." { if walkErr != nil { - return fmt.Errorf("read kubeconfig directory: %w", walkErr) + return errors.New("cannot read kubeconfig directory") } return nil } + entries++ + if entries > maxImportFiles { + result.diagnostics = append(result.diagnostics, importDiagnostic("", "kubeconfig entry limit reached")) + return errImportLimit + } if walkErr != nil { result.diagnostics = append(result.diagnostics, importDiagnostic(relative, "unreadable entry")) if entry != nil && entry.IsDir() { @@ -83,6 +89,10 @@ func loadDirectory(root string) (importedConfig, error) { } return nil } + if entry == nil { + result.diagnostics = append(result.diagnostics, importDiagnostic(relative, "unreadable entry")) + return nil + } if entry.Type()&os.ModeSymlink != 0 { result.diagnostics = append(result.diagnostics, importDiagnostic(relative, "symlink input ignored")) return nil @@ -99,7 +109,7 @@ func loadDirectory(root string) (importedConfig, error) { } candidates++ if candidates > maxImportFiles { - result.diagnostics = append(result.diagnostics, importDiagnostic("", "kubeconfig file limit reached")) + result.diagnostics = append(result.diagnostics, importDiagnostic("", "kubeconfig entry limit reached")) return errImportLimit } fileInfo, statErr := entry.Info() @@ -123,7 +133,7 @@ func loadDirectory(root string) (importedConfig, error) { err = nil } if err != nil { - return importedConfig{}, fmt.Errorf("scan kubeconfig directory: %w", err) + return importedConfig{}, errors.New("cannot scan kubeconfig directory") } sort.Slice(result.diagnostics, func(left, right int) bool { if result.diagnostics[left].Source == result.diagnostics[right].Source { diff --git a/internal/connector/kubeconfig/directory_test.go b/internal/connector/kubeconfig/directory_test.go index ef8d51e..bb169f8 100644 --- a/internal/connector/kubeconfig/directory_test.go +++ b/internal/connector/kubeconfig/directory_test.go @@ -5,6 +5,7 @@ package kubeconfig import ( "context" "encoding/json" + "fmt" "os" "path/filepath" "slices" @@ -117,13 +118,42 @@ func TestDirectoryImportRejectsUnsafeRoots(t *testing.T) { if err := os.Symlink(t.TempDir(), linkedRoot); err != nil { t.Fatal(err) } - for _, path := range []string{"", regularFile, linkedRoot} { + missing := filepath.Join(t.TempDir(), "missing") + for _, path := range []string{"", regularFile, linkedRoot, missing} { if _, err := New(WithDirectory(path)); err == nil { t.Errorf("New(WithDirectory(%q)) error = nil, want unsafe root refusal", path) + } else if path != "" && strings.Contains(err.Error(), path) { + t.Errorf("New(WithDirectory(%q)) exposed the path in %q", path, err) } } } +func TestDirectoryImportBoundsAllTraversalEntries(t *testing.T) { + t.Parallel() + directory := t.TempDir() + target := filepath.Join(t.TempDir(), "outside.yaml") + if err := os.WriteFile(target, []byte("apiVersion: v1\n"), 0o600); err != nil { + t.Fatal(err) + } + for index := 0; index <= maxImportFiles; index++ { + path := filepath.Join(directory, fmt.Sprintf("entry-%03d", index)) + if err := os.Symlink(target, path); err != nil { + t.Fatal(err) + } + } + imported, err := loadDirectory(directory) + if err != nil { + t.Fatalf("loadDirectory() error = %v", err) + } + limitReported := false + for _, diagnostic := range imported.diagnostics { + limitReported = limitReported || (diagnostic.Source == "" && diagnostic.Message == "kubeconfig entry limit reached") + } + if len(imported.diagnostics) != maxImportFiles+1 || !limitReported { + t.Fatalf("diagnostics = %#v, want bounded traversal diagnostic", imported.diagnostics) + } +} + func TestDirectoryImportConflictsWithExplicitSource(t *testing.T) { t.Parallel() directory := t.TempDir() diff --git a/sessions/2026-07-14-f11-directory-import-review-fixes.md b/sessions/2026-07-14-f11-directory-import-review-fixes.md new file mode 100644 index 0000000..f88e791 --- /dev/null +++ b/sessions/2026-07-14-f11-directory-import-review-fixes.md @@ -0,0 +1,22 @@ +# Session — 2026-07-14 — F11 directory-import review corrections + +**Builder:** Gnani Rahul · **Model/effort:** Codex / autonomous implementation · **Branch:** `gnanirahulnutakki/fix/f11-directory-import-review` +**Slice(s):** F11.7 / #162 · **Status:** validated; corrective PR pending + +--- + +[G] Goal: Close the review gaps discovered after F11.7 directory-import PR #163 merged, without broadening the local-only product scope. + +[S] Scope: the four valid CodeRabbit review findings against #163: total directory-walk bounds, root-error privacy, multi-context source-filter coverage, and duplicate e2e UI startup logic. Out: changes to generic cache contracts, native app packaging, kubeconfig persistence, or network behavior. + +[A] Action: Cap every non-root traversed filesystem entry before its type is inspected, preserving the regular-file cap and safe diagnostics; remove path-bearing root traversal errors; retain a two-context `first.yaml` fixture plus a nested second source and assert the comma-separated source filter returns both selected contexts' records; and share the e2e web UI process bootstrap helper. README now accurately states that the 128-entry bound includes ignored symlinks and directories. + +[R] Review: The late CodeRabbit review of #163 also reported a docstring-coverage threshold warning from its own configuration. It is not a repository gate and does not identify a missing public API contract. The four concrete findings were verified as valid and corrected here. + +[T] Test: Exact-source validation passed: targeted `go test -race -count=1 ./internal/connector/kubeconfig ./internal/cli ./internal/webui`; `make ci`; `make e2e-isolation` including the 50,000x cross-workspace fuzz campaign; `make release-check` with two reproducible four-platform archive/SBOM passes; and `KIND=/Volumes/EXTENDED/MacData/tools/bin/kind make e2e-kind` in 154.553 seconds. Temporary kind clusters were deleted by the test harness; `kind get clusters` reports none. + +[C] Continuity: #163 merged to `dev` as `9a6c0e99cd07b626201d9504e1ab4179b54d692a`; its exact post-merge CI run `29373871098` and CodeQL run `29373870789` both passed. Dependabot, code-scanning, and secret-scanning queues were `0 / 0 / 0` after that merge. Keep #162 open until this corrective PR is merged and its own exact `dev` checks pass. + +--- + +**Session close:** ready for signed/DCO/GSTACK commit and peer review. diff --git a/tests/e2e/kind_fanout_test.go b/tests/e2e/kind_fanout_test.go index b38333c..e3c8b4f 100644 --- a/tests/e2e/kind_fanout_test.go +++ b/tests/e2e/kind_fanout_test.go @@ -752,24 +752,32 @@ func splitKindKubeconfigDirectory(t *testing.T, mergedPath string, clusters []st t.Fatalf("load merged kind kubeconfig: %v", err) } directory := t.TempDir() - for index, cluster := range clusters { + first := clientcmdapi.NewConfig() + for _, cluster := range clusters { contextName := "kind-" + cluster contextConfig, exists := merged.Contexts[contextName] if !exists { t.Fatalf("merged kubeconfig missing context %q", contextName) } - config := clientcmdapi.NewConfig() - config.Clusters[contextConfig.Cluster] = merged.Clusters[contextConfig.Cluster].DeepCopy() - config.AuthInfos[contextConfig.AuthInfo] = merged.AuthInfos[contextConfig.AuthInfo].DeepCopy() - config.Contexts[contextName] = contextConfig.DeepCopy() - config.CurrentContext = contextName - filename := filepath.Join(directory, "first.yaml") - if index == 1 { - filename = filepath.Join(directory, "nested", "second.yaml") - } - if err := clientcmd.WriteToFile(*config, filename); err != nil { - t.Fatalf("write imported kind kubeconfig %q: %v", filename, err) - } + first.Clusters[contextConfig.Cluster] = merged.Clusters[contextConfig.Cluster].DeepCopy() + first.AuthInfos[contextConfig.AuthInfo] = merged.AuthInfos[contextConfig.AuthInfo].DeepCopy() + first.Contexts[contextName] = contextConfig.DeepCopy() + first.CurrentContext = contextName + } + firstPath := filepath.Join(directory, "first.yaml") + if err := clientcmd.WriteToFile(*first, firstPath); err != nil { + t.Fatalf("write imported kind kubeconfig %q: %v", firstPath, err) + } + secondContext := "kind-" + clusters[len(clusters)-1] + contextConfig := merged.Contexts[secondContext] + second := clientcmdapi.NewConfig() + second.Clusters[contextConfig.Cluster] = merged.Clusters[contextConfig.Cluster].DeepCopy() + second.AuthInfos[contextConfig.AuthInfo] = merged.AuthInfos[contextConfig.AuthInfo].DeepCopy() + second.Contexts[secondContext] = contextConfig.DeepCopy() + second.CurrentContext = secondContext + secondPath := filepath.Join(directory, "nested", "second.yaml") + if err := clientcmd.WriteToFile(*second, secondPath); err != nil { + t.Fatalf("write imported kind kubeconfig %q: %v", secondPath, err) } return directory } diff --git a/tests/e2e/kind_web_ui_test.go b/tests/e2e/kind_web_ui_test.go index bbed74f..4e37822 100644 --- a/tests/e2e/kind_web_ui_test.go +++ b/tests/e2e/kind_web_ui_test.go @@ -198,7 +198,7 @@ func exerciseWebUIDirectoryImport( for { response := webUIRequest(ctx, t, client, http.MethodGet, origin, "/api/v1/snapshot?kind=Pod", token, nil) decodeWebUIJSON(t, response, http.StatusOK, &snapshot) - if snapshot.Coverage.Requested == 2 && snapshot.Coverage.Reachable == 2 && len(snapshot.Scopes) == 2 { + if snapshot.Coverage.Requested == 3 && snapshot.Coverage.Reachable == 3 && len(snapshot.Scopes) == 3 { break } select { @@ -212,38 +212,48 @@ func exerciseWebUIDirectoryImport( if strings.Contains(mustMarshalWebUISnapshot(t, snapshot), kubeconfigDirectory) { t.Fatalf("directory-import snapshot exposed absolute directory: %#v", snapshot) } - byOrigin := make(map[string]string, len(snapshot.Scopes)) + byOrigin := make(map[string][]string, len(snapshot.Scopes)) for _, scope := range snapshot.Scopes { if scope.DisplayName == "" || scope.Origin == "" || scope.Name == scope.DisplayName { t.Fatalf("directory-import scope metadata = %#v", scope) } - byOrigin[scope.Origin] = scope.Name + byOrigin[scope.Origin] = append(byOrigin[scope.Origin], scope.Name) } - if len(byOrigin) != 2 || byOrigin["first.yaml"] == "" || byOrigin["nested/second.yaml"] == "" { + if len(byOrigin) != 2 || len(byOrigin["first.yaml"]) != 2 || len(byOrigin["nested/second.yaml"]) != 1 { t.Fatalf("directory-import source groups = %#v", byOrigin) } selected := byOrigin["first.yaml"] + selectedScopes := strings.Join(selected, ",") filtered := webUIRequest( ctx, t, client, http.MethodGet, origin, - "/api/v1/snapshot?kind=Pod&scopes="+url.QueryEscape(selected), token, nil, + "/api/v1/snapshot?kind=Pod&scopes="+url.QueryEscape(selectedScopes), token, nil, ) var oneSource fleetcache.Snapshot decodeWebUIJSON(t, filtered, http.StatusOK, &oneSource) - if len(oneSource.Scopes) != 1 || oneSource.Scopes[0].Name != selected || oneSource.Coverage.Requested != 1 { + if len(oneSource.Scopes) != 2 || oneSource.Coverage.Requested != 2 || oneSource.Coverage.Reachable != 2 { t.Fatalf("directory-import source selection = %#v", oneSource) } + allowed := make(map[string]struct{}, len(selected)) + for _, scope := range selected { + allowed[scope] = struct{}{} + } + observed := make(map[string]struct{}, len(selected)) for _, record := range oneSource.Records { - if record.Cluster != selected { + if _, ok := allowed[record.Cluster]; !ok { t.Fatalf("directory-import selection returned another source: %#v", oneSource.Records) } + observed[record.Cluster] = struct{}{} + } + if len(observed) != len(allowed) { + t.Fatalf("directory-import selection omitted selected contexts: %#v", oneSource.Records) } objectPath := webUITargetPath("/api/v1/object", localops.Target{ - Context: selected, Namespace: "default", Kind: "Pod", Name: "sith-local-ops", + Context: selected[0], Namespace: "default", Kind: "Pod", Name: "sith-local-ops", }) object := webUIRequest(ctx, t, client, http.MethodGet, origin, objectPath, token, nil) var viewed webUIObject decodeWebUIJSON(t, object, http.StatusOK, &viewed) - if viewed.Target.Context != selected || !strings.Contains(viewed.YAML, "sith-local-ops") || + if viewed.Target.Context != selected[0] || !strings.Contains(viewed.YAML, "sith-local-ops") || !strings.Contains(viewed.YAML, "kind-"+clusters[0]) { t.Fatalf("directory-import object read = %#v/%q", viewed.Target, viewed.YAML) } @@ -344,59 +354,26 @@ func startWebUI(ctx context.Context, t *testing.T, binary, kubeconfigPath string "KUBECONFIG="+kubeconfigPath, "XDG_CONFIG_HOME="+filepath.Join(filepath.Dir(kubeconfigPath), "config-home-web"), ) - stdout, err := command.StdoutPipe() - if err != nil { - t.Fatalf("web UI stdout: %v", err) - } - process := &webUIProcess{command: command, stderr: &bytes.Buffer{}} - command.Stderr = process.stderr - if err := command.Start(); err != nil { - t.Fatalf("start web UI: %v", err) - } - lines := make(chan string, 1) - go func() { - scanner := bufio.NewScanner(stdout) - if scanner.Scan() { - lines <- scanner.Text() - } - close(lines) - }() - timer := time.NewTimer(30 * time.Second) - defer timer.Stop() - select { - case line, open := <-lines: - if !open { - process.stop(t) - t.Fatalf("web UI exited before reporting its address: %s", process.stderr.String()) - } - match := webUIAddressPattern.FindStringSubmatch(line) - if len(match) != 2 { - process.stop(t) - t.Fatalf("web UI address line = %q", line) - } - return process, match[1] - case <-timer.C: - process.stop(t) - t.Fatalf("web UI did not report its address: %s", process.stderr.String()) - case <-ctx.Done(): - process.stop(t) - t.Fatalf("web UI context ended before startup: %v", ctx.Err()) - } - return nil, "" + return startWebUIProcess(ctx, t, command) } func startWebUIFromDirectory(ctx context.Context, t *testing.T, binary, kubeconfigDirectory string) (*webUIProcess, string) { t.Helper() command := exec.Command(binary, "ui", "--no-open", "--address", "127.0.0.1", "--port", "0", "--kubeconfig-dir", kubeconfigDirectory) command.Env = append(os.Environ(), "XDG_CONFIG_HOME="+filepath.Join(kubeconfigDirectory, "config-home-web")) + return startWebUIProcess(ctx, t, command) +} + +func startWebUIProcess(ctx context.Context, t *testing.T, command *exec.Cmd) (*webUIProcess, string) { + t.Helper() stdout, err := command.StdoutPipe() if err != nil { - t.Fatalf("directory-import web UI stdout: %v", err) + t.Fatalf("web UI stdout: %v", err) } process := &webUIProcess{command: command, stderr: &bytes.Buffer{}} command.Stderr = process.stderr if err := command.Start(); err != nil { - t.Fatalf("start directory-import web UI: %v", err) + t.Fatalf("start web UI: %v", err) } lines := make(chan string, 1) go func() { @@ -412,20 +389,20 @@ func startWebUIFromDirectory(ctx context.Context, t *testing.T, binary, kubeconf case line, open := <-lines: if !open { process.stop(t) - t.Fatalf("directory-import web UI exited before reporting its address: %s", process.stderr.String()) + t.Fatalf("web UI exited before reporting its address: %s", process.stderr.String()) } match := webUIAddressPattern.FindStringSubmatch(line) if len(match) != 2 { process.stop(t) - t.Fatalf("directory-import web UI address line = %q", line) + t.Fatalf("web UI address line = %q", line) } return process, match[1] case <-timer.C: process.stop(t) - t.Fatalf("directory-import web UI did not report its address: %s", process.stderr.String()) + t.Fatalf("web UI did not report its address: %s", process.stderr.String()) case <-ctx.Done(): process.stop(t) - t.Fatalf("directory-import web UI context ended before startup: %v", ctx.Err()) + t.Fatalf("web UI context ended before startup: %v", ctx.Err()) } return nil, "" } From dfa4fb9263ec864f931ea67cd5d07dc89a1caea8 Mon Sep 17 00:00:00 2001 From: Gnani Rahul Date: Tue, 14 Jul 2026 18:36:54 -0500 Subject: [PATCH 4/7] fix(f11): remove redundant directory candidate bound Keep the all-entry traversal cap as the sole directory-import limit after peer review. GSTACK-Checkpoint: 2026-07-14/f11-directory-entry-cap#1 Signed-off-by: Gnani Rahul --- internal/connector/kubeconfig/directory.go | 6 ----- ...6-07-14-f11-directory-entry-cap-cleanup.md | 22 +++++++++++++++++++ 2 files changed, 22 insertions(+), 6 deletions(-) create mode 100644 sessions/2026-07-14-f11-directory-entry-cap-cleanup.md diff --git a/internal/connector/kubeconfig/directory.go b/internal/connector/kubeconfig/directory.go index 631a86d..4065eb4 100644 --- a/internal/connector/kubeconfig/directory.go +++ b/internal/connector/kubeconfig/directory.go @@ -63,7 +63,6 @@ func loadDirectory(root string) (importedConfig, error) { raw: clientcmdapi.NewConfig(), metadata: make(map[string]contextMetadata), } - candidates := 0 entries := 0 err = filepath.WalkDir(absolute, func(path string, entry fs.DirEntry, walkErr error) error { relative, relativeErr := filepath.Rel(absolute, path) @@ -107,11 +106,6 @@ func loadDirectory(root string) (importedConfig, error) { if !entry.Type().IsRegular() { return nil } - candidates++ - if candidates > maxImportFiles { - result.diagnostics = append(result.diagnostics, importDiagnostic("", "kubeconfig entry limit reached")) - return errImportLimit - } fileInfo, statErr := entry.Info() if statErr != nil || fileInfo.Size() > maxImportBytes { message := "unreadable kubeconfig" diff --git a/sessions/2026-07-14-f11-directory-entry-cap-cleanup.md b/sessions/2026-07-14-f11-directory-entry-cap-cleanup.md new file mode 100644 index 0000000..20153c9 --- /dev/null +++ b/sessions/2026-07-14-f11-directory-entry-cap-cleanup.md @@ -0,0 +1,22 @@ +# Session — 2026-07-14 — F11 directory entry-cap cleanup + +**Builder:** Gnani Rahul · **Model/effort:** Codex / autonomous implementation · **Branch:** `gnanirahulnutakki/fix/f11-directory-entry-cap` +**Slice(s):** F11.7 / #162 · **Status:** validated; final cleanup PR pending + +--- + +[G] Goal: Remove the one redundant regular-file counter discovered by the completed peer review of PR #164, leaving a single auditable directory-entry bound. + +[S] Scope: `internal/connector/kubeconfig/directory.go` only. Out: changes to import behavior, user-visible limits, diagnostics, source grouping, tests, or product documentation. + +[A] Action: Removed the candidate counter and its unreachable limit branch. Every non-root directory traversal entry remains counted before type-specific handling, so the existing 128-entry rejection and safe `kubeconfig entry limit reached` diagnostic are unchanged. + +[R] Review: CodeRabbit's completed review of #164 correctly identified the candidate check as unreachable because `entries` is incremented first and candidates are a strict subset. The cleanup implements that feedback directly. The reviewer’s session-journal scope warning is not actionable: the repository workflow requires session journals, and this record provides the review/validation evidence without changing product behavior. + +[T] Test: Exact-source validation passed: `go test -race -count=1 ./internal/connector/kubeconfig`; `make ci`; `make e2e-isolation` including the 50,000x cross-workspace fuzz campaign; `make release-check` with two reproducible four-platform archive/SBOM passes; and `KIND=/Volumes/EXTENDED/MacData/tools/bin/kind make e2e-kind` in 153.155 seconds. Temporary kind clusters were deleted by the harness. + +[C] Continuity: PR #164 merged to `dev` as `5061c4f44e802568c3d2929f36e9d3c1ba0ab211`; exact post-merge CI `29375898259` and CodeQL `29375897905` passed. Keep #162 open until this final cleanup PR is green, merged, and verified on the resulting `dev` commit. + +--- + +**Session close:** ready for signed/DCO/GSTACK commit and final review. From 78af530c25853f378ba9316bb0aa938c9b3833ea Mon Sep 17 00:00:00 2001 From: Gnani Rahul Date: Tue, 14 Jul 2026 19:39:48 -0500 Subject: [PATCH 5/7] feat(f11): add native macOS fleet desktop Package the existing local fleet IDE in a Wails v2 macOS shell with a native, path-private kubeconfig-folder import flow. Preserve the hardened in-process UI boundary and atomic session handoff. GSTACK-Checkpoint: 2026-07-14/f11-native-desktop-shell#1 Signed-off-by: Gnani Rahul --- .gitignore | 1 + Makefile | 17 +- README.md | 10 ++ cmd/sith-desktop/main.go | 14 ++ cmd/sith-desktop/wails.json | 17 ++ docs/adr/0010-native-local-desktop-shell.md | 46 +++++ docs/adr/README.md | 1 + go.mod | 22 +++ go.sum | 61 +++++++ internal/cli/desktop.go | 160 ++++++++++++++++++ internal/cli/desktop_darwin.go | 85 ++++++++++ internal/cli/desktop_execute.go | 24 +++ internal/cli/desktop_other.go | 17 ++ internal/cli/desktop_test.go | 152 +++++++++++++++++ internal/cli/root.go | 1 + internal/privacy/boundary_test.go | 4 +- internal/webui/assets/app.js | 11 +- internal/webui/assets/index.html | 3 +- internal/webui/desktop.go | 51 ++++++ internal/webui/desktop_test.go | 59 +++++++ internal/webui/server.go | 15 +- internal/webui/server_test.go | 46 +++++ .../2026-07-14-f11-native-desktop-shell.md | 75 ++++++++ 23 files changed, 885 insertions(+), 7 deletions(-) create mode 100644 cmd/sith-desktop/main.go create mode 100644 cmd/sith-desktop/wails.json create mode 100644 docs/adr/0010-native-local-desktop-shell.md create mode 100644 internal/cli/desktop.go create mode 100644 internal/cli/desktop_darwin.go create mode 100644 internal/cli/desktop_execute.go create mode 100644 internal/cli/desktop_other.go create mode 100644 internal/cli/desktop_test.go create mode 100644 internal/webui/desktop.go create mode 100644 internal/webui/desktop_test.go create mode 100644 sessions/2026-07-14-f11-native-desktop-shell.md diff --git a/.gitignore b/.gitignore index 5481b22..ec1a953 100644 --- a/.gitignore +++ b/.gitignore @@ -37,5 +37,6 @@ coverage.* node_modules/ dist/ build/ +cmd/sith-desktop/frontend/wailsjs/ .next/ coverage/ diff --git a/Makefile b/Makefile index a1494af..3ffb9fe 100644 --- a/Makefile +++ b/Makefile @@ -10,6 +10,9 @@ GOVULNCHECK ?= govulncheck KIND ?= kind HELM ?= helm GORELEASER ?= goreleaser +WAILS ?= wails +CODESIGN ?= codesign +PLISTBUDDY ?= /usr/libexec/PlistBuddy DOCKER ?= docker KUBECTL ?= kubectl OCM_SCRATCH_ROOT ?= $(shell python3 -c 'import os; print(os.path.join(os.path.realpath(os.environ.get("TMPDIR", "/tmp")), "sith-m0-{}".format(os.getuid()), "lab"))') @@ -29,7 +32,7 @@ LDFLAGS := -s -w \ -X $(PKG)/internal/buildinfo.Commit=$(COMMIT) \ -X $(PKG)/internal/buildinfo.Date=$(DATE) -.PHONY: all build test test-scripts perf e2e e2e-helm e2e-oci e2e-kind e2e-ocm e2e-postgres e2e-isolation lint vuln fmt fmt-check vet tidy clean run ci release-check help +.PHONY: all build desktop-build test test-scripts perf e2e e2e-helm e2e-oci e2e-kind e2e-ocm e2e-postgres e2e-isolation lint vuln fmt fmt-check vet tidy clean run ci release-check help all: build @@ -37,6 +40,18 @@ build: ## Build the sith binary into bin/ @mkdir -p $(BIN_DIR) go build -trimpath -ldflags '$(LDFLAGS)' -o $(BIN_DIR)/$(BINARY) $(CMD) +desktop-build: ## Build the ad-hoc-signed macOS arm64 Sith.app development bundle + @command -v "$(WAILS)" >/dev/null || { echo "wails v2 is required" >&2; exit 1; } + @"$(WAILS)" version | grep -q 'v2\.' || { echo "Wails v2 is required" >&2; exit 1; } + cd cmd/sith-desktop && "$(WAILS)" build -clean -s -trimpath -platform darwin/arm64 + @set -euo pipefail; \ + app='cmd/sith-desktop/build/bin/Sith.app'; \ + test -d "$$app"; \ + "$(PLISTBUDDY)" -c 'Set :CFBundleIdentifier com.ardurai.sith' "$$app/Contents/Info.plist"; \ + "$(CODESIGN)" --force --sign - "$$app"; \ + "$(CODESIGN)" --verify --strict "$$app"; \ + plutil -extract CFBundleIdentifier raw -o - "$$app/Contents/Info.plist" | grep -qx 'com.ardurai.sith' + test: ## Run unit tests with the race detector and report coverage go test -race -count=1 -coverprofile=coverage.out ./... diff --git a/README.md b/README.md index 2439edc..655bbd4 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ make build ./bin/sith edit configmap/api-settings --context kind-dev -n apps ./bin/sith ui # loopback-only embedded fleet IDE ./bin/sith ui --kubeconfig-dir "$HOME/kubeconfigs" # import a folder of kubeconfig files for this UI session +./bin/sith desktop # native macOS window for the same local fleet IDE ./bin/sith serve --mcp # loopback-only MCP read server ./bin/sith serve --mcp --require-token ``` @@ -360,6 +361,15 @@ diff. Port-forward accepts loopback addresses only (`localhost`, `127.0.0.1`, or can hold API connections for its lifetime, but it creates no cloud resources or persistent local cache. +On macOS, `sith desktop` runs the same embedded fleet IDE in a native Wails v2 window. It uses an +in-process WebView origin (`wails://wails`), so it does not open a TCP listener. The **Import folder** +control appears only in that window and opens a native directory chooser; it passes the selection to +the identical bounded, in-memory kubeconfig importer used by `sith ui --kubeconfig-dir`. The UI +receives only success or cancellation, never the selected absolute path or kubeconfig content. Build +an ad-hoc-signed Apple Silicon development bundle with `make desktop-build`; public releases remain +blocked on Developer ID signing, notarization, stapling, and E9 release provenance, so this is not yet +a distributed replacement for Lens. + Each active lens holds one Kubernetes watch per reachable context after its initial list. A two-minute safety rediscovery recovers contexts that were offline at launch; it is not the primary resource refresh path. Very large context/lens counts therefore trade API-server connection and diff --git a/cmd/sith-desktop/main.go b/cmd/sith-desktop/main.go new file mode 100644 index 0000000..8e4c77a --- /dev/null +++ b/cmd/sith-desktop/main.go @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Sith desktop starts the native macOS shell for the local fleet IDE. +package main + +import ( + "os" + + "github.com/ArdurAI/sith/internal/cli" +) + +func main() { + os.Exit(cli.ExecuteDesktop()) +} diff --git a/cmd/sith-desktop/wails.json b/cmd/sith-desktop/wails.json new file mode 100644 index 0000000..157332a --- /dev/null +++ b/cmd/sith-desktop/wails.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://wails.io/schemas/config.v2.json", + "name": "Sith", + "outputfilename": "Sith", + "frontend:install": "", + "frontend:build": "true", + "author": { + "name": "ArdurAI", + "email": "security@ardur.ai" + }, + "info": { + "companyName": "ArdurAI", + "productName": "Sith", + "productVersion": "0.0.0-dev", + "comments": "Local-first Kubernetes fleet IDE" + } +} diff --git a/docs/adr/0010-native-local-desktop-shell.md b/docs/adr/0010-native-local-desktop-shell.md new file mode 100644 index 0000000..19114e7 --- /dev/null +++ b/docs/adr/0010-native-local-desktop-shell.md @@ -0,0 +1,46 @@ +# ADR 0010: Use a Wails v2 native shell for the local fleet IDE + +- Status: Accepted +- Date: 2026-07-14 + +## Context + +Sith already provides a build-free, loopback-only browser IDE through `sith ui`. +Operators also need a macOS application that feels local, including a native folder +chooser for a directory of kubeconfig files. The desktop form must retain the same +source-abstract engine, cache, local-operation boundaries, and privacy posture. + +## Decision + +Use Wails v2 as a thin macOS shell around the existing Go web UI handler. + +- Wails v2 is the upstream stable release line; Wails v3 is alpha and is not used. +- The app serves `webui.Application` through the Wails in-process asset server at + the exact `wails://wails` origin. It opens no TCP listener. +- The existing API handler, strict Host/Origin checks, per-process CSRF capability, + CSP, cache, hydrator, and local operation client remain the only implementation. +- The sole native binding opens a directory chooser. It returns only success or + cancellation to the UI; the selected path and kubeconfig contents never cross + the UI bridge, persist, or enter diagnostics. +- A successful selection builds a new bounded importer session before atomically + replacing the current in-memory session. A failing selection leaves the current + session intact. + +## Consequences + +The normal CLI remains browser-capable through `sith ui`, while `sith desktop` +opens the same fleet IDE as a local macOS window. `make desktop-build` produces a +development ARM64 `.app` with the stable `com.ardurai.sith` bundle identifier and +an ad-hoc signature. It is deliberately not a public release artifact until E9 +supplies Developer ID signing, notarization, stapling, and release provenance. + +The first native shell does not add complete Lens parity, telemetry, an updater, +remote control-plane access, Windows/Linux desktop support, or a second Kubernetes +client. Its Wails dependency and macOS runtime therefore become explicit package +review and release-gate responsibilities. + +## References + +- https://wails.io/docs/introduction/ +- https://wails.io/docs/guides/dynamic-assets/ +- https://wails.io/docs/guides/signing/ diff --git a/docs/adr/README.md b/docs/adr/README.md index 0e7221c..5cd0dcf 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -19,6 +19,7 @@ decision rests on an external fact, that fact is web-verified and cited (see als | [0007](0007-local-mcp-transport-auth.md) | Local MCP transport, scope, and authentication | Accepted | | [0008](0008-deterministic-advisory-brain.md) | Deterministic local advisory brain and evidence contract | Accepted | | [0009](0009-release-supply-chain.md) | Reproducible and identity-bound release supply chain | Accepted | +| [0010](0010-native-local-desktop-shell.md) | Native local desktop shell | Accepted | Planning ADRs remain **Proposed** until their implementation lane accepts or rejects them. Implementation-specific ADRs may be **Accepted** when the corresponding shipped slice provides diff --git a/go.mod b/go.mod index 0b98af2..4bb888f 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 github.com/prometheus/client_golang v1.23.2 github.com/spf13/cobra v1.10.2 + github.com/wailsapp/wails/v2 v2.12.0 github.com/zalando/go-keyring v0.2.8 go.yaml.in/yaml/v3 v3.0.4 golang.org/x/term v0.45.0 @@ -25,7 +26,9 @@ require ( ) require ( + git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/bep/debounce v1.2.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 // indirect @@ -40,6 +43,7 @@ require ( github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-logr/logr v1.4.3 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/swag v0.23.0 // indirect @@ -52,27 +56,45 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/labstack/echo/v4 v4.13.3 // indirect + github.com/labstack/gommon v0.4.2 // indirect + github.com/leaanthony/go-ansi-parser v1.6.1 // indirect + github.com/leaanthony/gosod v1.0.4 // indirect + github.com/leaanthony/slicer v1.6.0 // indirect + github.com/leaanthony/u v1.1.1 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.23 // indirect github.com/moby/spdystream v0.5.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/samber/lo v1.49.1 // indirect github.com/segmentio/asm v1.1.3 // indirect github.com/segmentio/encoding v0.5.4 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/tkrajina/go-reflector v0.5.8 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + github.com/wailsapp/go-webview2 v1.0.22 // indirect + github.com/wailsapp/mimetype v1.4.1 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect + golang.org/x/crypto v0.51.0 // indirect golang.org/x/net v0.55.0 // indirect golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sync v0.21.0 // indirect diff --git a/go.sum b/go.sum index d80cef2..cf62e0f 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,15 @@ charm.land/bubbletea/v2 v2.0.8 h1:SxTJMhCAI3lbPmy4SgX5LWZ24AdINr4I6UEqzZvYJuY= charm.land/bubbletea/v2 v2.0.8/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss= +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA= +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= +github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= @@ -42,6 +46,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= @@ -77,6 +83,8 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -92,10 +100,32 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= +github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc= +github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA= +github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A= +github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU= +github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI= +github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw= +github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js= +github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8= +github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M= +github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= +github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/moby/spdystream v0.5.1 h1:9sNYeYZUcci9R6/w7KDaFWEWeV4LStVG78Mpyq/Zm/Y= @@ -112,6 +142,10 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -123,11 +157,14 @@ github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9Z github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= +github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= @@ -148,6 +185,18 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= +github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58= +github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= +github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= +github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= +github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c= +github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= @@ -174,22 +223,34 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= diff --git a/internal/cli/desktop.go b/internal/cli/desktop.go new file mode 100644 index 0000000..4fa4eb8 --- /dev/null +++ b/internal/cli/desktop.go @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "context" + "fmt" + "strings" + "sync" + + "github.com/spf13/cobra" + + "github.com/ArdurAI/sith/internal/connector" + "github.com/ArdurAI/sith/internal/connector/kubeconfig" + "github.com/ArdurAI/sith/internal/fleetcache" + "github.com/ArdurAI/sith/internal/hydrate" + "github.com/ArdurAI/sith/internal/localops" + "github.com/ArdurAI/sith/internal/webui" +) + +type desktopOptions struct { + kubeconfigDir string +} + +func newDesktopCommand(reader connector.Reader, local localops.Client) *cobra.Command { + options := &desktopOptions{} + command := &cobra.Command{ + Use: "desktop", + Short: "Open the native local fleet IDE on macOS", + Args: cobra.NoArgs, + RunE: func(command *cobra.Command, _ []string) error { + if reader == nil || local == nil { + return fmt.Errorf("local fleet desktop requires a Kubernetes reader and local operations client") + } + return runDesktop(command.Context(), reader, local, options.kubeconfigDir) + }, + } + command.Flags().StringVar(&options.kubeconfigDir, "kubeconfig-dir", "", "import kubeconfig files from this directory for this local desktop session") + return command +} + +type desktopSourceFactory func(string) (connector.Reader, localops.Client, error) + +type desktopSession struct { + cancel context.CancelFunc + application *webui.Application + handler webui.LocalHandler +} + +func newDesktopSession(parent context.Context, reader connector.Reader, local localops.Client) (*desktopSession, error) { + ctx, cancel := context.WithCancel(parent) + store := fleetcache.New() + hydrator, err := hydrate.New(reader, store) + if err != nil { + cancel() + return nil, err + } + application, err := webui.New(ctx, store, hydrator, local) + if err != nil { + cancel() + return nil, err + } + handler, err := application.Handler(webui.DesktopOrigin) + if err != nil { + _ = application.Close() + cancel() + return nil, err + } + go func() { _ = hydrator.Run(ctx) }() + return &desktopSession{cancel: cancel, application: application, handler: handler}, nil +} + +func (session *desktopSession) close() { + if session == nil { + return + } + session.cancel() + _ = session.application.Close() +} + +// desktopHost swaps complete in-memory sessions after a native folder choice. +// It never persists or returns the selected filesystem path. +type desktopHost struct { + ctx context.Context + newSource desktopSourceFactory + + mu sync.RWMutex + closed bool + session *desktopSession + handler *webui.InProcessHandler +} + +func newDesktopHost(ctx context.Context, reader connector.Reader, local localops.Client) (*desktopHost, error) { + if reader == nil || local == nil { + return nil, fmt.Errorf("construct local fleet desktop: Kubernetes access is unavailable") + } + host := &desktopHost{ + ctx: ctx, + newSource: desktopDirectorySource, + } + session, err := newDesktopSession(ctx, reader, local) + if err != nil { + return nil, err + } + host.session = session + host.handler = webui.NewInProcessHandler(session.handler) + return host, nil +} + +func desktopDirectorySource(directory string) (connector.Reader, localops.Client, error) { + adapter, err := kubeconfig.New(kubeconfig.WithDirectory(directory)) + if err != nil { + return nil, nil, err + } + return adapter, adapter, nil +} + +func (host *desktopHost) Handler() webui.LocalHandler { + return host.handler +} + +func (host *desktopHost) importDirectory(directory string) error { + if strings.TrimSpace(directory) == "" { + return fmt.Errorf("import selected kubeconfig directory") + } + reader, local, err := host.newSource(directory) + if err != nil { + return fmt.Errorf("import selected kubeconfig directory") + } + next, err := newDesktopSession(host.ctx, reader, local) + if err != nil { + return fmt.Errorf("open selected kubeconfig directory") + } + host.mu.Lock() + if host.closed { + host.mu.Unlock() + next.close() + return fmt.Errorf("open selected kubeconfig directory") + } + previous := host.session + host.handler.Replace(next.handler) + host.session = next + host.mu.Unlock() + previous.close() + return nil +} + +func (host *desktopHost) Close() { + host.mu.Lock() + if host.closed { + host.mu.Unlock() + return + } + host.closed = true + session := host.session + host.session = nil + host.handler.Replace(nil) + host.mu.Unlock() + session.close() +} diff --git a/internal/cli/desktop_darwin.go b/internal/cli/desktop_darwin.go new file mode 100644 index 0000000..ffe34f7 --- /dev/null +++ b/internal/cli/desktop_darwin.go @@ -0,0 +1,85 @@ +//go:build darwin + +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "context" + "fmt" + + "github.com/wailsapp/wails/v2" + "github.com/wailsapp/wails/v2/pkg/options" + "github.com/wailsapp/wails/v2/pkg/options/assetserver" + "github.com/wailsapp/wails/v2/pkg/runtime" + + "github.com/ArdurAI/sith/internal/connector" + "github.com/ArdurAI/sith/internal/localops" + "github.com/ArdurAI/sith/internal/webui" +) + +// DesktopBridge is the only native capability exposed to the embedded UI. +// It returns a boolean, never the selected directory path. +type DesktopBridge struct { + ctx context.Context + host *desktopHost +} + +// ChooseKubeconfigDirectory opens the native directory picker and replaces the +// in-memory source only after the existing bounded importer accepts it. +func (bridge *DesktopBridge) ChooseKubeconfigDirectory() (bool, error) { + directory, err := runtime.OpenDirectoryDialog(bridge.ctx, runtime.OpenDialogOptions{ + Title: "Import kubeconfig folder", + CanCreateDirectories: false, + ShowHiddenFiles: false, + }) + if err != nil { + // Native dialog errors may include local filesystem details, so never + // return the underlying error across the WebView bridge. + return false, fmt.Errorf("select kubeconfig directory") + } + if directory == "" { + return false, nil + } + if err := bridge.host.importDirectory(directory); err != nil { + return false, err + } + return true, nil +} + +func runDesktop(ctx context.Context, reader connector.Reader, local localops.Client, directory string) error { + if directory != "" { + var err error + reader, local, err = desktopDirectorySource(directory) + if err != nil { + // Import errors can contain a selected local path; the CLI receives + // a stable category rather than that private detail. + return fmt.Errorf("import selected kubeconfig directory") + } + } + host, err := newDesktopHost(ctx, reader, local) + if err != nil { + return err + } + bridge := &DesktopBridge{host: host} + err = wails.Run(&options.App{ + Title: "Sith — Fleet IDE", + Width: 1440, + Height: 900, + MinWidth: 960, + MinHeight: 640, + BackgroundColour: &options.RGBA{R: 16, G: 24, B: 32, A: 255}, + OnStartup: func(appContext context.Context) { bridge.ctx = appContext }, + OnShutdown: func(context.Context) { host.Close() }, + Bind: []interface{}{bridge}, + EnableDefaultContextMenu: false, + AssetServer: &assetserver.Options{ + Middleware: webui.InProcessMiddleware(host.Handler()), + }, + }) + if err != nil { + host.Close() + return fmt.Errorf("start local fleet desktop: %w", err) + } + return nil +} diff --git a/internal/cli/desktop_execute.go b/internal/cli/desktop_execute.go new file mode 100644 index 0000000..acff144 --- /dev/null +++ b/internal/cli/desktop_execute.go @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "context" + "fmt" + "os" + "os/signal" + + "github.com/ArdurAI/sith/internal/connector/kubeconfig" +) + +// ExecuteDesktop runs the packaged macOS application entry point. +func ExecuteDesktop() int { + adapter := kubeconfig.Default() + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + if err := runDesktop(ctx, adapter, adapter, ""); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + return 1 + } + return 0 +} diff --git a/internal/cli/desktop_other.go b/internal/cli/desktop_other.go new file mode 100644 index 0000000..67bbe8d --- /dev/null +++ b/internal/cli/desktop_other.go @@ -0,0 +1,17 @@ +//go:build !darwin + +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "context" + "fmt" + + "github.com/ArdurAI/sith/internal/connector" + "github.com/ArdurAI/sith/internal/localops" +) + +func runDesktop(context.Context, connector.Reader, localops.Client, string) error { + return fmt.Errorf("local fleet desktop is currently available only on macOS") +} diff --git a/internal/cli/desktop_test.go b/internal/cli/desktop_test.go new file mode 100644 index 0000000..835a8dc --- /dev/null +++ b/internal/cli/desktop_test.go @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "fmt" + "net/http" + "net/http/httptest" + "regexp" + "strings" + "testing" + + "github.com/ArdurAI/sith/internal/connector" + "github.com/ArdurAI/sith/internal/localops" + "github.com/ArdurAI/sith/internal/webui" +) + +func TestDesktopHostServesTheExistingUIWithoutATCPListener(t *testing.T) { + t.Parallel() + host, err := newDesktopHost(t.Context(), &cacheReader{}, &fakeLocalClient{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(host.Close) + indexRequest := httptest.NewRequest(http.MethodGet, webui.DesktopOrigin+"/", nil) + indexRequest.Host = "wails" + index := httptest.NewRecorder() + host.Handler().ServeHTTP(index, indexRequest) + match := regexp.MustCompile(`name="sith-csrf-token" content="([^"]+)"`).FindStringSubmatch(index.Body.String()) + if index.Code != http.StatusOK || len(match) != 2 { + t.Fatalf("desktop index = %d/%s", index.Code, index.Body.String()) + } + request := httptest.NewRequest(http.MethodGet, webui.DesktopOrigin+"/api/v1/meta", nil) + request.Host = "wails" + request.Header.Set("X-Sith-CSRF", match[1]) + request.Header.Set("Origin", webui.DesktopOrigin) + response := httptest.NewRecorder() + host.Handler().ServeHTTP(response, request) + if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"telemetry":false`) { + t.Fatalf("desktop response = %d/%s", response.Code, response.Body.String()) + } +} + +func TestDesktopFolderImportUsesTheSharedSourceSeam(t *testing.T) { + t.Parallel() + host, err := newDesktopHost(t.Context(), &cacheReader{}, &fakeLocalClient{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(host.Close) + selected := t.TempDir() + "/team-kubeconfigs" + called := "" + host.newSource = func(directory string) (connector.Reader, localops.Client, error) { + called = directory + return &cacheReader{}, &fakeLocalClient{}, nil + } + if err := host.importDirectory(selected); err != nil { + t.Fatal(err) + } + if called != selected { + t.Fatalf("selected directory = %q, want %q", called, selected) + } +} + +func TestDesktopFolderImportRedactsFailure(t *testing.T) { + t.Parallel() + host, err := newDesktopHost(t.Context(), &cacheReader{}, &fakeLocalClient{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(host.Close) + selected := t.TempDir() + "/team-kubeconfigs" + host.newSource = func(directory string) (connector.Reader, localops.Client, error) { + return nil, nil, fmt.Errorf("unreadable %s", directory) + } + if err := host.importDirectory(selected); err == nil || strings.Contains(err.Error(), selected) { + t.Fatalf("import failure = %v, want redacted error", err) + } +} + +func TestDesktopFolderImportKeepsTheActiveSessionWhenReplacementCannotStart(t *testing.T) { + t.Parallel() + host, err := newDesktopHost(t.Context(), &cacheReader{}, &fakeLocalClient{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(host.Close) + previous := host.session + selected := t.TempDir() + "/team-kubeconfigs" + host.newSource = func(string) (connector.Reader, localops.Client, error) { + return &cacheReader{}, nil, nil + } + if err := host.importDirectory(selected); err == nil || strings.Contains(err.Error(), selected) { + t.Fatalf("import failure = %v, want redacted replacement error", err) + } + if host.session != previous { + t.Fatal("failed import replaced the active desktop session") + } + response := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, webui.DesktopOrigin+"/", nil) + request.Host = "wails" + host.Handler().ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("active session status after failed import = %d", response.Code) + } +} + +func TestDesktopFolderImportCannotReviveAClosedHost(t *testing.T) { + t.Parallel() + host, err := newDesktopHost(t.Context(), &cacheReader{}, &fakeLocalClient{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(host.Close) + started := make(chan struct{}) + release := make(chan struct{}) + host.newSource = func(string) (connector.Reader, localops.Client, error) { + close(started) + <-release + return &cacheReader{}, &fakeLocalClient{}, nil + } + result := make(chan error, 1) + go func() { result <- host.importDirectory(t.TempDir() + "/team-kubeconfigs") }() + select { + case <-started: + case <-t.Context().Done(): + t.Fatal("import did not reach source construction") + } + host.Close() + close(release) + if err := <-result; err == nil { + t.Fatal("closed desktop host accepted a replacement session") + } + if host.session != nil { + t.Fatal("closed desktop host retained a session") + } + response := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, webui.DesktopOrigin+"/", nil) + request.Host = "wails" + host.Handler().ServeHTTP(response, request) + if response.Code != http.StatusServiceUnavailable { + t.Fatalf("closed desktop handler status = %d, want %d", response.Code, http.StatusServiceUnavailable) + } +} + +func TestDesktopDirectorySourceRejectsUnsafeInputWithoutPathLeak(t *testing.T) { + t.Parallel() + unsafe := t.TempDir() + "/missing-kubeconfigs" + if _, _, err := desktopDirectorySource(unsafe); err == nil || strings.Contains(err.Error(), unsafe) { + t.Fatalf("desktopDirectorySource() error = %v, want safe rejection", err) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index b80cf14..a8c93fa 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -144,6 +144,7 @@ func newRootCommand(runtime backend, stdout, stderr io.Writer) *cobra.Command { newVersionCommand(options), newClustersCommand(options, runtime.source), newUICommand(runtime.reader, runtime.local), + newDesktopCommand(runtime.reader, runtime.local), newHubCommand(), } if runtime.reader != nil { diff --git a/internal/privacy/boundary_test.go b/internal/privacy/boundary_test.go index cdb3b9e..056a95e 100644 --- a/internal/privacy/boundary_test.go +++ b/internal/privacy/boundary_test.go @@ -43,7 +43,9 @@ var approvedNetworkImports = map[string]map[string]bool{ "internal/mcpserver/server.go": {"net": true, "net/http": true, "net/url": true}, "internal/observability/metrics.go": {"net/http": true}, "internal/webui/api.go": {"net/http": true}, - "internal/webui/server.go": {"net": true, "net/http": true, "net/url": true}, + // In-process Wails WebView routing; it has no socket listener or egress path. + "internal/webui/desktop.go": {"net/http": true}, + "internal/webui/server.go": {"net": true, "net/http": true, "net/url": true}, } var approvedFilesystemWrites = map[string]map[string]bool{ diff --git a/internal/webui/assets/app.js b/internal/webui/assets/app.js index 22199ac..de2eee6 100644 --- a/internal/webui/assets/app.js +++ b/internal/webui/assets/app.js @@ -18,7 +18,7 @@ const dom = Object.fromEntries([ "query-mode", "context-list", "board-heading", "board-kicker", "result-count", "fleet-rows", "empty-state", "coverage-line", "inspector-empty", "inspector-content", "inspector-kind", "inspector-name", "inspector-address", "inspector-facts", "operation-grid", "refresh-button", - "forwards-button", "forward-count", "toast-region", "action-dialog", "dialog-title", + "forwards-button", "forward-count", "import-folder-button", "toast-region", "action-dialog", "dialog-title", "dialog-kicker", "dialog-body", "dialog-actions", "dialog-close", "loading-template", ].map((id) => [id, document.getElementById(id)])); @@ -417,6 +417,15 @@ dom["query-mode"].addEventListener("click", () => { }); dom["refresh-button"].addEventListener("click", async () => { try { await api("/api/v1/sync", {method: "POST", body: "{}"}); toast("Fleet refresh scheduled."); } catch (error) { toast(error.message, "error"); } }); dom["forwards-button"].addEventListener("click", showForwards); +const directoryPicker = window.go?.cli?.DesktopBridge?.ChooseKubeconfigDirectory; +if (typeof directoryPicker === "function") { + dom["import-folder-button"].hidden = false; + dom["import-folder-button"].addEventListener("click", async () => { + try { + if (await directoryPicker()) window.location.reload(); + } catch (error) { toast(error.message || "Unable to import folder.", "error"); } + }); +} dom["dialog-close"].addEventListener("click", () => dom["action-dialog"].close()); dom["action-dialog"].addEventListener("close", () => { state.logAbort?.abort(); state.logAbort = null; }); document.addEventListener("keydown", (event) => { diff --git a/internal/webui/assets/index.html b/internal/webui/assets/index.html index dfe89c7..479ffd8 100644 --- a/internal/webui/assets/index.html +++ b/internal/webui/assets/index.html @@ -24,7 +24,8 @@

Sith / live

warming contexts Cache rows appear as clusters answer. -
+
+
diff --git a/internal/webui/desktop.go b/internal/webui/desktop.go new file mode 100644 index 0000000..3b53001 --- /dev/null +++ b/internal/webui/desktop.go @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: Apache-2.0 + +package webui + +import ( + "net/http" + "sync" +) + +// LocalHandler is a same-process HTTP request handler. It exists so a native +// WebView can reuse the hardened web UI without opening a TCP listener. +type LocalHandler interface { + ServeHTTP(http.ResponseWriter, *http.Request) +} + +// InProcessHandler routes one request at a time to the active local UI session. +// Replace waits for an in-flight request before the caller closes the old session. +type InProcessHandler struct { + mu sync.RWMutex + current LocalHandler +} + +// NewInProcessHandler constructs a handler whose active UI session can be +// replaced without starting a network listener. +func NewInProcessHandler(initial LocalHandler) *InProcessHandler { + return &InProcessHandler{current: initial} +} + +func (handler *InProcessHandler) ServeHTTP(response http.ResponseWriter, request *http.Request) { + handler.mu.RLock() + defer handler.mu.RUnlock() + if handler.current == nil { + http.Error(response, "local desktop is shutting down", http.StatusServiceUnavailable) + return + } + handler.current.ServeHTTP(response, request) +} + +// Replace waits for the active request, then atomically selects the next local +// UI session. A nil handler makes future requests fail closed. +func (handler *InProcessHandler) Replace(next LocalHandler) { + handler.mu.Lock() + handler.current = next + handler.mu.Unlock() +} + +// InProcessMiddleware adapts a local handler to Wails without using the +// framework default asset route or opening a listener. +func InProcessMiddleware(handler LocalHandler) func(http.Handler) http.Handler { + return func(http.Handler) http.Handler { return handler } +} diff --git a/internal/webui/desktop_test.go b/internal/webui/desktop_test.go new file mode 100644 index 0000000..8baf326 --- /dev/null +++ b/internal/webui/desktop_test.go @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: Apache-2.0 + +package webui + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +type blockingLocalHandler struct { + started chan<- struct{} + release <-chan struct{} +} + +func (handler blockingLocalHandler) ServeHTTP(response http.ResponseWriter, _ *http.Request) { + close(handler.started) + <-handler.release + response.WriteHeader(http.StatusNoContent) +} + +func TestInProcessHandlerWaitsForRequestsBeforeReplacement(t *testing.T) { + t.Parallel() + started := make(chan struct{}) + release := make(chan struct{}) + handler := NewInProcessHandler(blockingLocalHandler{started: started, release: release}) + served := make(chan struct{}) + go func() { + handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "wails://wails/", nil)) + close(served) + }() + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("initial in-process request did not start") + } + replaced := make(chan struct{}) + go func() { + handler.Replace(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + close(replaced) + }() + select { + case <-replaced: + t.Fatal("replacement completed while the previous request was in flight") + case <-time.After(20 * time.Millisecond): + } + close(release) + select { + case <-served: + case <-time.After(time.Second): + t.Fatal("initial in-process request did not finish") + } + select { + case <-replaced: + case <-time.After(time.Second): + t.Fatal("replacement did not finish after the prior request completed") + } +} diff --git a/internal/webui/server.go b/internal/webui/server.go index 1919108..f2faaf2 100644 --- a/internal/webui/server.go +++ b/internal/webui/server.go @@ -27,6 +27,10 @@ import ( const ( csrfHeader = "X-Sith-CSRF" localMode = "local" + + // DesktopOrigin is Wails' in-process macOS WebView origin. It never binds a + // TCP listener and is accepted only by the native desktop host. + DesktopOrigin = "wails://wails" ) //go:embed assets/* @@ -85,11 +89,16 @@ func New(ctx context.Context, store *fleetcache.Store, syncer Syncer, local loca }, nil } -// Handler returns the hardened frontend/API handler for one exact listener URL. +// Handler returns the hardened frontend/API handler for one exact local origin. func (application *Application) Handler(baseURL string) (http.Handler, error) { parsed, err := url.Parse(baseURL) - if err != nil || parsed.Scheme != "http" || parsed.Host == "" || parsed.Path != "" { - return nil, fmt.Errorf("configure web UI handler: base URL must be an http origin") + if err != nil || parsed.Host == "" || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" { + return nil, fmt.Errorf("configure web UI handler: base URL must be an exact local origin") + } + if baseURL != DesktopOrigin { + if parsed.Scheme != "http" || ValidateLoopbackAddress(parsed.Hostname()) != nil { + return nil, fmt.Errorf("configure web UI handler: base URL must be a loopback http origin") + } } mux := http.NewServeMux() mux.HandleFunc("GET /", application.serveIndex) diff --git a/internal/webui/server_test.go b/internal/webui/server_test.go index 3427e42..674cc62 100644 --- a/internal/webui/server_test.go +++ b/internal/webui/server_test.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "io" + "io/fs" "net/http" "net/http/httptest" "slices" @@ -88,6 +89,51 @@ func TestHandlerEnforcesHostOriginCapabilityAndSecurityHeaders(t *testing.T) { } } +func TestHandlerAllowsOnlyTheExplicitDesktopOrigin(t *testing.T) { + t.Parallel() + application := testApplication(t) + handler, err := application.Handler(DesktopOrigin) + if err != nil { + t.Fatalf("Handler(%q) error = %v", DesktopOrigin, err) + } + request := httptest.NewRequest(http.MethodGet, DesktopOrigin+"/api/v1/meta", nil) + request.Host = "wails" + request.Header.Set(csrfHeader, application.token) + request.Header.Set("Origin", DesktopOrigin) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("desktop meta status/body = %d/%s", recorder.Code, recorder.Body.String()) + } + for _, origin := range []string{"https://wails.localhost", "http://example.com", "wails://attacker"} { + if _, err := application.Handler(origin); err == nil { + t.Errorf("Handler(%q) error = nil", origin) + } + } +} + +func TestDesktopFolderBridgeIsOptInAndDoesNotExposePaths(t *testing.T) { + t.Parallel() + index, err := fs.ReadFile(embeddedAssets, "assets/index.html") + if err != nil { + t.Fatal(err) + } + script, err := fs.ReadFile(embeddedAssets, "assets/app.js") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(index), `id="import-folder-button" class="quiet-action" type="button" hidden`) { + t.Fatal("desktop import control is not hidden by default") + } + if !strings.Contains(string(script), "window.go?.cli?.DesktopBridge?.ChooseKubeconfigDirectory") || + !strings.Contains(string(script), "if (await directoryPicker()) window.location.reload()") { + t.Fatal("desktop bridge is not opt-in or does not reload after a successful source swap") + } + if strings.Contains(string(script), "selectedDirectory") || strings.Contains(string(script), "kubeconfigDir") { + t.Fatal("desktop bridge must not retain a selected local path in the UI") + } +} + func TestSnapshotReadsCacheOnlyAndRefreshIsExplicit(t *testing.T) { t.Parallel() syncer := &webSyncer{} diff --git a/sessions/2026-07-14-f11-native-desktop-shell.md b/sessions/2026-07-14-f11-native-desktop-shell.md new file mode 100644 index 0000000..1a6add1 --- /dev/null +++ b/sessions/2026-07-14-f11-native-desktop-shell.md @@ -0,0 +1,75 @@ +# F11.8 native local desktop shell + +Issue: [#166](https://github.com/ArdurAI/sith/issues/166) + +Branch: `gnanirahulnutakki/feat/f11-native-desktop-shell` + +Base: `origin/dev` at `c6fa47bb63a269025bfcb3ab9f8042bb02d71edb` + +## [G] Goal + +Add the first native macOS form of Sith's local fleet IDE without creating a +second Kubernetes client, a TCP listener, account state, telemetry, or a path +leak across the UI bridge. + +## [D] Decision + +- ADR 0010 adopts stable Wails v2 as a thin native shell; Wails v3 remains + alpha and is not selected. +- The native WebView serves the existing `webui.Application` at the exact + `wails://wails` origin through Wails' in-process asset-server middleware. +- The only native bridge method opens a directory chooser. It returns success + or cancellation only; the chosen path and kubeconfig contents never enter + JavaScript, diagnostics, or persistent state. +- A choice creates a bounded kubeconfig-import source and complete replacement + in-memory session before atomically replacing the active handler. Failure + retains the active session. +- `make desktop-build` creates an Apple Silicon development bundle with stable + `com.ardurai.sith` identity and an ad-hoc signature. Developer ID signing, + notarization, stapling, and release provenance remain E9 follow-up work. + +## [A] Red-team review + +- The source passes explicit `wails://wails` only to the existing hardened + Host/Origin/CSRF/CSP handler; all other non-loopback origins remain rejected. +- `InProcessHandler` holds a read lock for the full request. Replacement waits + for it, then the prior application closes only after the handler swap, so a + request cannot observe a closed session. +- `sith desktop --kubeconfig-dir` constructs the bounded directory source + before the desktop host, avoiding default-kubeconfig hydration before the + explicit import validates. +- Browser mode has no `window.go` bridge, so the import control remains hidden. +- CodeRabbit's complete staged review found a major close/import race and a + minor error-wrapping suggestion. The major race is fixed with terminal host + state and a deterministic regression test. The minor is intentionally not + applied: native dialog and kubeconfig errors can contain local paths, so the + bridge and CLI return stable redacted categories. Later CodeRabbit calls + stopped after remote summarization and retained only the prior findings; + this is not represented as a fresh clean external-review verdict. + +## [T] Tests and evidence + +- Focused `go test -race -count=1 ./internal/cli ./internal/webui`: PASS. +- Replacement-preserves-session and in-flight-handler-replacement tests: PASS; + the pair is stable across 50 local repetitions. +- Final `make ci`: PASS (format, vet, lint, reachable-vulnerability scan with + no findings, race tests, safety scripts, performance, binary e2e in 18.265s, + and production build). +- Final `make e2e-isolation`: PASS (forced PostgreSQL RLS tests and 50,000-case + cross-workspace selector fuzz campaign). +- Final `make release-check`: PASS (two verified Darwin/Linux amd64/arm64 + snapshots, SPDX SBOMs, formula rendering, and deterministic digests). +- Final `make e2e-kind`: PASS in 158.742 seconds for real two-cluster fleet + fanout and OCI image contracts. `kind get clusters` and the Sith-named Docker + container check were empty afterward. +- Final `make desktop-build WAILS=/Volumes/EXTENDED/MacData/go/bin/wails`: + PASS with Wails CLI v2.12.0. The resulting app is ARM64, bundle identifier + `com.ardurai.sith`, `Signature=adhoc`, and `TeamIdentifier=not set`. +- `go run ./cmd/sith desktop --help`: PASS with the expected desktop and + `--kubeconfig-dir` contract. +- `git diff --check`: PASS before review/staging. + +## [C] Checkpoint + +- Implementation, full validation, staged peer/red-team review, PR, merge, and + exact post-merge CI remain to be recorded as their own signed/DCO checkpoints. From b99820eb7a0e6bf78e5c09ca23804c7111d3c43e Mon Sep 17 00:00:00 2001 From: Gnani Rahul Date: Tue, 14 Jul 2026 20:09:30 -0500 Subject: [PATCH 6/7] fix(f11): harden native desktop review gaps Use non-blocking session leases, a sanitized hydration-stop state, directory-only startup, graceful signal shutdown, and reproducible pinned Wails packaging with ARM64 verification. GSTACK-Checkpoint: 2026-07-14/f11-native-desktop-shell#2 Signed-off-by: Gnani Rahul --- Makefile | 9 ++- README.md | 3 +- cmd/sith-desktop/frontend/.gitkeep | 1 + cmd/sith-desktop/wails.json | 1 + docs/adr/0010-native-local-desktop-shell.md | 6 +- internal/cli/desktop.go | 39 ++++++++-- internal/cli/desktop_darwin.go | 46 ++++++++--- internal/cli/desktop_test.go | 60 ++++++++++++++- internal/webui/assets/app.js | 5 +- internal/webui/desktop.go | 77 +++++++++++++++---- internal/webui/desktop_test.go | 23 +++--- internal/webui/server_test.go | 3 + .../2026-07-14-f11-native-desktop-shell.md | 48 +++++++++--- 13 files changed, 260 insertions(+), 61 deletions(-) create mode 100644 cmd/sith-desktop/frontend/.gitkeep diff --git a/Makefile b/Makefile index 3ffb9fe..b00ab5f 100644 --- a/Makefile +++ b/Makefile @@ -11,8 +11,10 @@ KIND ?= kind HELM ?= helm GORELEASER ?= goreleaser WAILS ?= wails +WAILS_VERSION ?= v2.12.0 CODESIGN ?= codesign PLISTBUDDY ?= /usr/libexec/PlistBuddy +LIPO ?= lipo DOCKER ?= docker KUBECTL ?= kubectl OCM_SCRATCH_ROOT ?= $(shell python3 -c 'import os; print(os.path.join(os.path.realpath(os.environ.get("TMPDIR", "/tmp")), "sith-m0-{}".format(os.getuid()), "lab"))') @@ -41,12 +43,13 @@ build: ## Build the sith binary into bin/ go build -trimpath -ldflags '$(LDFLAGS)' -o $(BIN_DIR)/$(BINARY) $(CMD) desktop-build: ## Build the ad-hoc-signed macOS arm64 Sith.app development bundle - @command -v "$(WAILS)" >/dev/null || { echo "wails v2 is required" >&2; exit 1; } - @"$(WAILS)" version | grep -q 'v2\.' || { echo "Wails v2 is required" >&2; exit 1; } - cd cmd/sith-desktop && "$(WAILS)" build -clean -s -trimpath -platform darwin/arm64 + @command -v "$(WAILS)" >/dev/null || { echo "Wails $(WAILS_VERSION) is required" >&2; exit 1; } + @"$(WAILS)" version | grep -q '$(WAILS_VERSION)' || { echo "Wails $(WAILS_VERSION) is required" >&2; exit 1; } + cd cmd/sith-desktop && "$(WAILS)" build -clean -m -nosyncgomod -s -trimpath -platform darwin/arm64 @set -euo pipefail; \ app='cmd/sith-desktop/build/bin/Sith.app'; \ test -d "$$app"; \ + "$(LIPO)" -archs "$$app/Contents/MacOS/Sith" | grep -qx 'arm64'; \ "$(PLISTBUDDY)" -c 'Set :CFBundleIdentifier com.ardurai.sith' "$$app/Contents/Info.plist"; \ "$(CODESIGN)" --force --sign - "$$app"; \ "$(CODESIGN)" --verify --strict "$$app"; \ diff --git a/README.md b/README.md index 655bbd4..84bbdbd 100644 --- a/README.md +++ b/README.md @@ -365,7 +365,8 @@ On macOS, `sith desktop` runs the same embedded fleet IDE in a native Wails v2 w in-process WebView origin (`wails://wails`), so it does not open a TCP listener. The **Import folder** control appears only in that window and opens a native directory chooser; it passes the selection to the identical bounded, in-memory kubeconfig importer used by `sith ui --kubeconfig-dir`. The UI -receives only success or cancellation, never the selected absolute path or kubeconfig content. Build +receives success, cancellation, or a sanitized failure category—never the selected absolute path or +kubeconfig content. Build an ad-hoc-signed Apple Silicon development bundle with `make desktop-build`; public releases remain blocked on Developer ID signing, notarization, stapling, and E9 release provenance, so this is not yet a distributed replacement for Lens. diff --git a/cmd/sith-desktop/frontend/.gitkeep b/cmd/sith-desktop/frontend/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/cmd/sith-desktop/frontend/.gitkeep @@ -0,0 +1 @@ + diff --git a/cmd/sith-desktop/wails.json b/cmd/sith-desktop/wails.json index 157332a..f4ed803 100644 --- a/cmd/sith-desktop/wails.json +++ b/cmd/sith-desktop/wails.json @@ -2,6 +2,7 @@ "$schema": "https://wails.io/schemas/config.v2.json", "name": "Sith", "outputfilename": "Sith", + "frontend:dir": "frontend", "frontend:install": "", "frontend:build": "true", "author": { diff --git a/docs/adr/0010-native-local-desktop-shell.md b/docs/adr/0010-native-local-desktop-shell.md index 19114e7..65cc649 100644 --- a/docs/adr/0010-native-local-desktop-shell.md +++ b/docs/adr/0010-native-local-desktop-shell.md @@ -19,9 +19,9 @@ Use Wails v2 as a thin macOS shell around the existing Go web UI handler. the exact `wails://wails` origin. It opens no TCP listener. - The existing API handler, strict Host/Origin checks, per-process CSRF capability, CSP, cache, hydrator, and local operation client remain the only implementation. -- The sole native binding opens a directory chooser. It returns only success or - cancellation to the UI; the selected path and kubeconfig contents never cross - the UI bridge, persist, or enter diagnostics. +- The sole native binding opens a directory chooser. It returns success, + cancellation, or a sanitized failure category to the UI; the selected path + and kubeconfig contents never cross the UI bridge, persist, or enter diagnostics. - A successful selection builds a new bounded importer session before atomically replacing the current in-memory session. A failing selection leaves the current session intact. diff --git a/internal/cli/desktop.go b/internal/cli/desktop.go index 4fa4eb8..30dc7a4 100644 --- a/internal/cli/desktop.go +++ b/internal/cli/desktop.go @@ -4,6 +4,7 @@ package cli import ( "context" + "errors" "fmt" "strings" "sync" @@ -29,8 +30,8 @@ func newDesktopCommand(reader connector.Reader, local localops.Client) *cobra.Co Short: "Open the native local fleet IDE on macOS", Args: cobra.NoArgs, RunE: func(command *cobra.Command, _ []string) error { - if reader == nil || local == nil { - return fmt.Errorf("local fleet desktop requires a Kubernetes reader and local operations client") + if err := validateDesktopDependencies(reader, local, options.kubeconfigDir); err != nil { + return err } return runDesktop(command.Context(), reader, local, options.kubeconfigDir) }, @@ -66,10 +67,20 @@ func newDesktopSession(parent context.Context, reader connector.Reader, local lo cancel() return nil, err } - go func() { _ = hydrator.Run(ctx) }() + go runDesktopHydration(ctx, store, hydrator.Run) return &desktopSession{cancel: cancel, application: application, handler: handler}, nil } +const desktopHydrationStopped = "live cache refresh stopped; re-import the folder or restart Sith" + +func runDesktopHydration(ctx context.Context, store *fleetcache.Store, run func(context.Context) error) { + if err := run(ctx); err != nil && ctx.Err() == nil { + // The cache/API exposes only a closed operational category. Raw watch + // errors can carry cluster-specific details and do not cross this boundary. + store.EndSync(errors.New(desktopHydrationStopped)) + } +} + func (session *desktopSession) close() { if session == nil { return @@ -115,6 +126,16 @@ func desktopDirectorySource(directory string) (connector.Reader, localops.Client return adapter, adapter, nil } +func validateDesktopDependencies(reader connector.Reader, local localops.Client, directory string) error { + if strings.TrimSpace(directory) != "" { + return nil + } + if reader == nil || local == nil { + return fmt.Errorf("local fleet desktop requires a Kubernetes reader and local operations client") + } + return nil +} + func (host *desktopHost) Handler() webui.LocalHandler { return host.handler } @@ -138,13 +159,21 @@ func (host *desktopHost) importDirectory(directory string) error { return fmt.Errorf("open selected kubeconfig directory") } previous := host.session - host.handler.Replace(next.handler) + drained := host.handler.Replace(next.handler) host.session = next host.mu.Unlock() - previous.close() + go closeDesktopSessionAfter(drained, previous) return nil } +func closeDesktopSessionAfter(drained <-chan struct{}, session *desktopSession) { + if drained == nil || session == nil { + return + } + <-drained + session.close() +} + func (host *desktopHost) Close() { host.mu.Lock() if host.closed { diff --git a/internal/cli/desktop_darwin.go b/internal/cli/desktop_darwin.go index ffe34f7..495bdf4 100644 --- a/internal/cli/desktop_darwin.go +++ b/internal/cli/desktop_darwin.go @@ -7,6 +7,7 @@ package cli import ( "context" "fmt" + "sync" "github.com/wailsapp/wails/v2" "github.com/wailsapp/wails/v2/pkg/options" @@ -62,24 +63,51 @@ func runDesktop(ctx context.Context, reader connector.Reader, local localops.Cli return err } bridge := &DesktopBridge{host: host} + started := make(chan context.Context, 1) + stopped := make(chan struct{}) + var stopOnce sync.Once + stop := func() { stopOnce.Do(func() { close(stopped) }) } + go quitDesktopOnCancellation(ctx, started, stopped, runtime.Quit) err = wails.Run(&options.App{ - Title: "Sith — Fleet IDE", - Width: 1440, - Height: 900, - MinWidth: 960, - MinHeight: 640, - BackgroundColour: &options.RGBA{R: 16, G: 24, B: 32, A: 255}, - OnStartup: func(appContext context.Context) { bridge.ctx = appContext }, - OnShutdown: func(context.Context) { host.Close() }, + Title: "Sith — Fleet IDE", + Width: 1440, + Height: 900, + MinWidth: 960, + MinHeight: 640, + BackgroundColour: &options.RGBA{R: 16, G: 24, B: 32, A: 255}, + OnStartup: func(appContext context.Context) { + bridge.ctx = appContext + select { + case started <- appContext: + case <-stopped: + } + }, + OnShutdown: func(context.Context) { + stop() + host.Close() + }, Bind: []interface{}{bridge}, EnableDefaultContextMenu: false, AssetServer: &assetserver.Options{ Middleware: webui.InProcessMiddleware(host.Handler()), }, }) + stop() + host.Close() if err != nil { - host.Close() return fmt.Errorf("start local fleet desktop: %w", err) } return nil } + +func quitDesktopOnCancellation(parent context.Context, started <-chan context.Context, stopped <-chan struct{}, quit func(context.Context)) { + select { + case <-parent.Done(): + select { + case appContext := <-started: + quit(appContext) + case <-stopped: + } + case <-stopped: + } +} diff --git a/internal/cli/desktop_test.go b/internal/cli/desktop_test.go index 835a8dc..51b9b7c 100644 --- a/internal/cli/desktop_test.go +++ b/internal/cli/desktop_test.go @@ -3,14 +3,19 @@ package cli import ( + "context" "fmt" "net/http" "net/http/httptest" "regexp" "strings" + "sync" "testing" + "time" "github.com/ArdurAI/sith/internal/connector" + "github.com/ArdurAI/sith/internal/fleet" + "github.com/ArdurAI/sith/internal/fleetcache" "github.com/ArdurAI/sith/internal/localops" "github.com/ArdurAI/sith/internal/webui" ) @@ -114,6 +119,9 @@ func TestDesktopFolderImportCannotReviveAClosedHost(t *testing.T) { t.Cleanup(host.Close) started := make(chan struct{}) release := make(chan struct{}) + var releaseOnce sync.Once + releaseSource := func() { releaseOnce.Do(func() { close(release) }) } + t.Cleanup(releaseSource) host.newSource = func(string) (connector.Reader, localops.Client, error) { close(started) <-release @@ -123,13 +131,18 @@ func TestDesktopFolderImportCannotReviveAClosedHost(t *testing.T) { go func() { result <- host.importDirectory(t.TempDir() + "/team-kubeconfigs") }() select { case <-started: - case <-t.Context().Done(): + case <-time.After(time.Second): t.Fatal("import did not reach source construction") } host.Close() - close(release) - if err := <-result; err == nil { - t.Fatal("closed desktop host accepted a replacement session") + releaseSource() + select { + case err := <-result: + if err == nil { + t.Fatal("closed desktop host accepted a replacement session") + } + case <-time.After(time.Second): + t.Fatal("closed desktop host did not finish the interrupted import") } if host.session != nil { t.Fatal("closed desktop host retained a session") @@ -150,3 +163,42 @@ func TestDesktopDirectorySourceRejectsUnsafeInputWithoutPathLeak(t *testing.T) { t.Fatalf("desktopDirectorySource() error = %v, want safe rejection", err) } } + +func TestDesktopDependenciesAllowAnExplicitDirectorySource(t *testing.T) { + t.Parallel() + if err := validateDesktopDependencies(nil, nil, t.TempDir()); err != nil { + t.Fatalf("validateDesktopDependencies() error = %v, want explicit directory accepted", err) + } + if err := validateDesktopDependencies(nil, nil, ""); err == nil { + t.Fatal("validateDesktopDependencies() error = nil, want missing default source rejected") + } +} + +func TestDesktopHydrationFailureIsSanitizedInTheFleetCache(t *testing.T) { + t.Parallel() + store := fleetcache.New() + runDesktopHydration(context.Background(), store, func(context.Context) error { + return fmt.Errorf("watch /private/kubeconfigs/team.yaml failed") + }) + snapshot := store.Query(fleet.LocalWorkspace, fleetcache.Query{}) + if snapshot.LastError != desktopHydrationStopped || strings.Contains(snapshot.LastError, "/private/") { + t.Fatalf("hydration failure = %q, want sanitized category", snapshot.LastError) + } +} + +func TestQuitDesktopOnCancellationAfterStartup(t *testing.T) { + t.Parallel() + parent, cancel := context.WithCancel(t.Context()) + defer cancel() + started := make(chan context.Context, 1) + stopped := make(chan struct{}) + quit := make(chan struct{}, 1) + go quitDesktopOnCancellation(parent, started, stopped, func(context.Context) { quit <- struct{}{} }) + started <- context.Background() + cancel() + select { + case <-quit: + case <-time.After(time.Second): + t.Fatal("desktop cancellation did not request native shutdown") + } +} diff --git a/internal/webui/assets/app.js b/internal/webui/assets/app.js index de2eee6..8c88bac 100644 --- a/internal/webui/assets/app.js +++ b/internal/webui/assets/app.js @@ -1,6 +1,7 @@ "use strict"; const csrfToken = document.querySelector('meta[name="sith-csrf-token"]').content; +const desktopHydrationFailure = "live cache refresh stopped; re-import the folder or restart Sith"; const state = { meta: null, snapshot: null, @@ -85,7 +86,9 @@ function renderSnapshot() { const snapshot = state.snapshot; const coverage = snapshot.coverage || {}; dom["coverage-count"].textContent = `${coverage.reachable || 0} of ${coverage.requested || 0} contexts answering`; - dom["coverage-detail"].textContent = snapshot.state === "offline" ? "Offline — last-known fleet remains visible." : coverageText(coverage); + const coverageDetail = snapshot.state === "offline" ? "Offline — last-known fleet remains visible." : coverageText(coverage); + const safeDesktopFailure = snapshot.last_error === desktopHydrationFailure ? snapshot.last_error : ""; + dom["coverage-detail"].textContent = safeDesktopFailure ? `${coverageDetail} · ${safeDesktopFailure}` : coverageDetail; dom["coverage-line"].textContent = coverageText(coverage); dom["board-heading"].textContent = state.correlate || state.query ? "Fleet results" : `${state.lens}s`; dom["board-kicker"].textContent = state.correlate ? "Correlation answer" : state.query ? "Filtered cache" : "Aggregated lens"; diff --git a/internal/webui/desktop.go b/internal/webui/desktop.go index 3b53001..e9469a0 100644 --- a/internal/webui/desktop.go +++ b/internal/webui/desktop.go @@ -13,35 +13,86 @@ type LocalHandler interface { ServeHTTP(http.ResponseWriter, *http.Request) } -// InProcessHandler routes one request at a time to the active local UI session. -// Replace waits for an in-flight request before the caller closes the old session. +// InProcessHandler routes requests to the active local UI session. A replaced +// session remains leased only by requests that selected it before the swap. type InProcessHandler struct { - mu sync.RWMutex - current LocalHandler + mu sync.Mutex + current *inProcessSession +} + +type inProcessSession struct { + handler LocalHandler + active uint64 + retired bool + drained chan struct{} } // NewInProcessHandler constructs a handler whose active UI session can be // replaced without starting a network listener. func NewInProcessHandler(initial LocalHandler) *InProcessHandler { - return &InProcessHandler{current: initial} + handler := &InProcessHandler{} + if initial != nil { + handler.current = newInProcessSession(initial) + } + return handler } func (handler *InProcessHandler) ServeHTTP(response http.ResponseWriter, request *http.Request) { - handler.mu.RLock() - defer handler.mu.RUnlock() - if handler.current == nil { + session := handler.acquire() + if session == nil { http.Error(response, "local desktop is shutting down", http.StatusServiceUnavailable) return } - handler.current.ServeHTTP(response, request) + defer handler.release(session) + session.handler.ServeHTTP(response, request) } -// Replace waits for the active request, then atomically selects the next local -// UI session. A nil handler makes future requests fail closed. -func (handler *InProcessHandler) Replace(next LocalHandler) { +// Replace atomically selects the next local UI session. It returns a channel +// that closes once requests leased to the previous session have drained. A nil +// handler makes future requests fail closed without blocking new requests on an +// old, slow operation. +func (handler *InProcessHandler) Replace(next LocalHandler) <-chan struct{} { handler.mu.Lock() - handler.current = next + previous := handler.current + if next == nil { + handler.current = nil + } else { + handler.current = newInProcessSession(next) + } + if previous == nil { + handler.mu.Unlock() + return nil + } + previous.retired = true + if previous.active == 0 { + close(previous.drained) + } + drained := previous.drained handler.mu.Unlock() + return drained +} + +func newInProcessSession(next LocalHandler) *inProcessSession { + return &inProcessSession{handler: next, drained: make(chan struct{})} +} + +func (handler *InProcessHandler) acquire() *inProcessSession { + handler.mu.Lock() + defer handler.mu.Unlock() + if handler.current == nil { + return nil + } + handler.current.active++ + return handler.current +} + +func (handler *InProcessHandler) release(session *inProcessSession) { + handler.mu.Lock() + defer handler.mu.Unlock() + session.active-- + if session.retired && session.active == 0 { + close(session.drained) + } } // InProcessMiddleware adapts a local handler to Wails without using the diff --git a/internal/webui/desktop_test.go b/internal/webui/desktop_test.go index 8baf326..77a237f 100644 --- a/internal/webui/desktop_test.go +++ b/internal/webui/desktop_test.go @@ -20,7 +20,7 @@ func (handler blockingLocalHandler) ServeHTTP(response http.ResponseWriter, _ *h response.WriteHeader(http.StatusNoContent) } -func TestInProcessHandlerWaitsForRequestsBeforeReplacement(t *testing.T) { +func TestInProcessHandlerReplacesWithoutBlockingNewRequests(t *testing.T) { t.Parallel() started := make(chan struct{}) release := make(chan struct{}) @@ -35,16 +35,19 @@ func TestInProcessHandlerWaitsForRequestsBeforeReplacement(t *testing.T) { case <-time.After(time.Second): t.Fatal("initial in-process request did not start") } - replaced := make(chan struct{}) - go func() { - handler.Replace(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) - close(replaced) - }() + drained := handler.Replace(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + response.WriteHeader(http.StatusNoContent) + })) select { - case <-replaced: - t.Fatal("replacement completed while the previous request was in flight") + case <-drained: + t.Fatal("previous session drained while its request was in flight") case <-time.After(20 * time.Millisecond): } + fresh := httptest.NewRecorder() + handler.ServeHTTP(fresh, httptest.NewRequest(http.MethodGet, "wails://wails/", nil)) + if fresh.Code != http.StatusNoContent { + t.Fatalf("replacement handler status = %d, want %d", fresh.Code, http.StatusNoContent) + } close(release) select { case <-served: @@ -52,8 +55,8 @@ func TestInProcessHandlerWaitsForRequestsBeforeReplacement(t *testing.T) { t.Fatal("initial in-process request did not finish") } select { - case <-replaced: + case <-drained: case <-time.After(time.Second): - t.Fatal("replacement did not finish after the prior request completed") + t.Fatal("previous session did not drain after its request completed") } } diff --git a/internal/webui/server_test.go b/internal/webui/server_test.go index 674cc62..77517ce 100644 --- a/internal/webui/server_test.go +++ b/internal/webui/server_test.go @@ -129,6 +129,9 @@ func TestDesktopFolderBridgeIsOptInAndDoesNotExposePaths(t *testing.T) { !strings.Contains(string(script), "if (await directoryPicker()) window.location.reload()") { t.Fatal("desktop bridge is not opt-in or does not reload after a successful source swap") } + if !strings.Contains(string(script), `snapshot.last_error === desktopHydrationFailure`) { + t.Fatal("desktop UI does not allowlist a sanitized hydration failure") + } if strings.Contains(string(script), "selectedDirectory") || strings.Contains(string(script), "kubeconfigDir") { t.Fatal("desktop bridge must not retain a selected local path in the UI") } diff --git a/sessions/2026-07-14-f11-native-desktop-shell.md b/sessions/2026-07-14-f11-native-desktop-shell.md index 1a6add1..4c788dd 100644 --- a/sessions/2026-07-14-f11-native-desktop-shell.md +++ b/sessions/2026-07-14-f11-native-desktop-shell.md @@ -18,12 +18,15 @@ leak across the UI bridge. alpha and is not selected. - The native WebView serves the existing `webui.Application` at the exact `wails://wails` origin through Wails' in-process asset-server middleware. -- The only native bridge method opens a directory chooser. It returns success - or cancellation only; the chosen path and kubeconfig contents never enter - JavaScript, diagnostics, or persistent state. +- The only native bridge method opens a directory chooser. It returns success, + cancellation, or a sanitized failure category only; the chosen path and + kubeconfig contents never enter JavaScript, diagnostics, or persistent state. - A choice creates a bounded kubeconfig-import source and complete replacement in-memory session before atomically replacing the active handler. Failure retains the active session. +- A replacement switches routing immediately. Requests already leased to the + prior session drain before it closes, so one slow request cannot block the + new fleet view. - `make desktop-build` creates an Apple Silicon development bundle with stable `com.ardurai.sith` identity and an ad-hoc signature. Developer ID signing, notarization, stapling, and release provenance remain E9 follow-up work. @@ -32,20 +35,24 @@ leak across the UI bridge. - The source passes explicit `wails://wails` only to the existing hardened Host/Origin/CSRF/CSP handler; all other non-loopback origins remain rejected. -- `InProcessHandler` holds a read lock for the full request. Replacement waits - for it, then the prior application closes only after the handler swap, so a - request cannot observe a closed session. +- `InProcessHandler` leases the selected session briefly under a mutex, then + releases the routing lock before invoking it. Replacement routes new requests + immediately and closes the prior application only after its leased requests drain. - `sith desktop --kubeconfig-dir` constructs the bounded directory source before the desktop host, avoiding default-kubeconfig hydration before the explicit import validates. - Browser mode has no `window.go` bridge, so the import control remains hidden. -- CodeRabbit's complete staged review found a major close/import race and a +- CodeRabbit's initial staged review found a major close/import race and a minor error-wrapping suggestion. The major race is fixed with terminal host state and a deterministic regression test. The minor is intentionally not applied: native dialog and kubeconfig errors can contain local paths, so the - bridge and CLI return stable redacted categories. Later CodeRabbit calls - stopped after remote summarization and retained only the prior findings; - this is not represented as a fresh clean external-review verdict. + bridge and CLI return stable redacted categories. +- The explicit hosted CodeRabbit review on PR #167 then found valid packaging, + dependency-override, non-starving session-handoff, hydration-status, native + signal-shutdown, and documentation gaps. All are fixed in the review + checkpoint with focused regression tests. The tool's suggested `-nomodsync` + spelling was verified against Wails v2.12.0 and corrected to its actual + `-nosyncgomod` flag. ## [T] Tests and evidence @@ -69,7 +76,24 @@ leak across the UI bridge. `--kubeconfig-dir` contract. - `git diff --check`: PASS before review/staging. +## [T] Review checkpoint evidence + +- PR #167 initial hosted CI: PASS (`build · vet · gofmt · lint · test · e2e` + in 10m46s; reproducible archives/SBOM/formula in 5m9s; all CodeQL analyses + and the explicit CodeRabbit review completed). +- Review-fix final `make ci`: PASS (binary e2e 24.797s and production build). +- Review-fix `make e2e-isolation`, `make release-check`, and `make e2e-kind`: + PASS; the final real kind suite took 154.406s and cleaned every temporary + cluster. +- Review-fix desktop bundle: Wails v2.12.0, `-m -nosyncgomod`, verified ARM64, + `com.ardurai.sith`, and ad-hoc signature: PASS. The Wails build left + `go.mod` unchanged. +- The local CodeRabbit pass found an unbounded wait in the close/import test; + it is corrected with one-second bounds and idempotent source-release cleanup, + then verified by focused race tests and the final full CI run. + ## [C] Checkpoint -- Implementation, full validation, staged peer/red-team review, PR, merge, and - exact post-merge CI remain to be recorded as their own signed/DCO checkpoints. +- Initial signed/DCO/GSTACK implementation checkpoint: `78af530`. +- Review-fix implementation, fresh peer pass, signed/DCO checkpoint, hosted CI, + merge, and exact post-merge CI remain to be recorded. From 44fad0935dd8d063e73746014fc5fb6ac952c9be Mon Sep 17 00:00:00 2001 From: Gnani Rahul Date: Tue, 14 Jul 2026 20:15:20 -0500 Subject: [PATCH 7/7] fix(f11): compile desktop cancellation tests on CI Signed-off-by: Gnani Rahul --- internal/cli/desktop.go | 14 ++++++++++++++ internal/cli/desktop_darwin.go | 12 ------------ sessions/2026-07-14-f11-native-desktop-shell.md | 9 +++++++-- 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/internal/cli/desktop.go b/internal/cli/desktop.go index 30dc7a4..9264e2a 100644 --- a/internal/cli/desktop.go +++ b/internal/cli/desktop.go @@ -81,6 +81,20 @@ func runDesktopHydration(ctx context.Context, store *fleetcache.Store, run func( } } +// quitDesktopOnCancellation defers native shutdown until Wails has supplied +// its application context, while allowing normal application shutdown to win. +func quitDesktopOnCancellation(parent context.Context, started <-chan context.Context, stopped <-chan struct{}, quit func(context.Context)) { + select { + case <-parent.Done(): + select { + case appContext := <-started: + quit(appContext) + case <-stopped: + } + case <-stopped: + } +} + func (session *desktopSession) close() { if session == nil { return diff --git a/internal/cli/desktop_darwin.go b/internal/cli/desktop_darwin.go index 495bdf4..4006b5d 100644 --- a/internal/cli/desktop_darwin.go +++ b/internal/cli/desktop_darwin.go @@ -99,15 +99,3 @@ func runDesktop(ctx context.Context, reader connector.Reader, local localops.Cli } return nil } - -func quitDesktopOnCancellation(parent context.Context, started <-chan context.Context, stopped <-chan struct{}, quit func(context.Context)) { - select { - case <-parent.Done(): - select { - case appContext := <-started: - quit(appContext) - case <-stopped: - } - case <-stopped: - } -} diff --git a/sessions/2026-07-14-f11-native-desktop-shell.md b/sessions/2026-07-14-f11-native-desktop-shell.md index 4c788dd..64d248c 100644 --- a/sessions/2026-07-14-f11-native-desktop-shell.md +++ b/sessions/2026-07-14-f11-native-desktop-shell.md @@ -91,9 +91,14 @@ leak across the UI bridge. - The local CodeRabbit pass found an unbounded wait in the close/import test; it is corrected with one-second bounds and idempotent source-release cleanup, then verified by focused race tests and the final full CI run. +- Hosted Linux CI then caught the cancellation-helper test compiling against a + macOS-only definition. The helper is now shared while Wails startup remains + Darwin-only; focused race suites and the final `make ci` pass after that + correction. ## [C] Checkpoint - Initial signed/DCO/GSTACK implementation checkpoint: `78af530`. -- Review-fix implementation, fresh peer pass, signed/DCO checkpoint, hosted CI, - merge, and exact post-merge CI remain to be recorded. +- Review-fix implementation checkpoint: `b99820e`. +- Linux-CI portability correction, fresh peer pass, signed/DCO checkpoint, + hosted CI, merge, and exact post-merge CI remain to be recorded.