From 4363226980b26005629a4bf226ddfbdc074ceea7 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Fri, 5 Jun 2026 20:48:01 -0700 Subject: [PATCH 01/11] X-Smart-Branch-Parent: main From 1ae21358f327b6c18419c76694a52e789faf0690 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Sun, 7 Jun 2026 02:08:59 -0700 Subject: [PATCH 02/11] feat: GWS native provider with gateway-managed OAuth refresh Replace GWS file staging with OpenShell provider system. The gateway stores client_id, client_secret, and refresh_token as provider credentials (secrets marked non-injectable), refreshes the access token automatically every ~55 minutes, and injects GOOGLE_WORKSPACE_CLI_TOKEN as a proxy-resolved placeholder into sandboxes. The L7 proxy resolves the placeholder in Authorization headers -- the sandbox never sees the real access token, refresh token, or client secret. Eliminates: stageGWSCreds, openshell-gws K8s Secret, launcher /secrets/gws volume mount, and GOOGLE_WORKSPACE_CLI_* env vars from profiles. Adds ProviderRefreshConfigure and ProviderRefreshRotate to the Gateway interface and CLI wrapper. Validated locally: GOOGLE_WORKSPACE_CLI_TOKEN is a proxy placeholder, Gmail API calls succeed, forced token rotation leaves the sandbox working. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- cmd/creds.go | 69 ------------------- cmd/creds_test.go | 6 +- cmd/helpers_test.go | 4 +- cmd/providers.go | 72 +++++++++++++++++++- cmd/teardown.go | 2 +- cmd/teardown_test.go | 4 +- cmd/up.go | 2 - gateways/ocp/gateway.toml | 5 +- internal/gateway/cli.go | 18 +++++ internal/gateway/gateway.go | 9 +++ internal/profile/profile.go | 44 +------------ openshell.toml | 10 +-- profiles/default.toml | 4 +- sandbox/launcher/main.go | 22 +------ sandbox/launcher/main_test.go | 51 ++------------ sandbox/profiles/atlassian-user.yaml | 39 +++++++++++ sandbox/profiles/gws.yaml | 99 ++++++++++++++++++++++++++++ 17 files changed, 261 insertions(+), 199 deletions(-) create mode 100644 sandbox/profiles/atlassian-user.yaml create mode 100644 sandbox/profiles/gws.yaml diff --git a/cmd/creds.go b/cmd/creds.go index ea10205..d0de4f9 100644 --- a/cmd/creds.go +++ b/cmd/creds.go @@ -3,10 +3,7 @@ package cmd import ( "context" "fmt" - "io" "os" - "os/exec" - "path/filepath" "github.com/robbycochran/harness-openshell/internal/k8s" "github.com/robbycochran/harness-openshell/internal/status" @@ -22,21 +19,6 @@ func ensureCreds(kc k8s.Runner, namespace string, force bool) error { status.Section("Setting up cluster credentials") status.Detailf("Namespace: %s", namespace) - // GWS credentials - status.Section("GWS") - if force && kc.SecretExists(ctx, "openshell-gws") { - kc.RunKubectl(ctx, "delete", "secret", "openshell-gws") - status.Info("Deleted existing secret") - } - - if kc.SecretExists(ctx, "openshell-gws") { - status.Info("openshell-gws: exists (use --force to recreate)") - } else { - if err := createGWSSecret(ctx, kc); err != nil { - status.Failf("GWS: %v", err) - } - } - // Atlassian credentials status.Section("Atlassian") if force && kc.SecretExists(ctx, "openshell-atlassian") { @@ -61,54 +43,3 @@ func ensureCreds(kc k8s.Runner, namespace string, force bool) error { return nil } - -func createGWSSecret(ctx context.Context, kc k8s.Runner) error { - gwsPath, err := exec.LookPath("gws") - if err != nil { - status.Info("GWS: not installed (skipping)") - return nil - } - - check := exec.Command(gwsPath, "auth", "status") - check.Stdout = io.Discard - check.Stderr = io.Discard - if check.Run() != nil { - status.Info("GWS: not authenticated (run 'gws auth login')") - return nil - } - - tmpDir, err := os.MkdirTemp("", "harness-gws-") - if err != nil { - return err - } - defer os.RemoveAll(tmpDir) - - credFile := filepath.Join(tmpDir, "credentials.json") - out, err := exec.Command(gwsPath, "auth", "export", "--unmasked").Output() - if err != nil { - status.Info("GWS: export failed (skipping)") - return nil - } - if err := os.WriteFile(credFile, out, 0o600); err != nil { - return fmt.Errorf("writing gws credentials: %w", err) - } - - args := []string{"create", "secret", "generic", "openshell-gws", - "--from-file=credentials.json=" + credFile} - - gwsConfigDir := os.Getenv("GWS_CONFIG_DIR") - if gwsConfigDir == "" { - home, _ := os.UserHomeDir() - gwsConfigDir = filepath.Join(home, ".config", "gws") - } - clientSecret := filepath.Join(gwsConfigDir, "client_secret.json") - if _, err := os.Stat(clientSecret); err == nil { - args = append(args, "--from-file=client_secret.json="+clientSecret) - } - - if _, err := kc.RunKubectl(ctx, args...); err != nil { - return fmt.Errorf("creating gws secret: %w", err) - } - status.OK("openshell-gws: created") - return nil -} diff --git a/cmd/creds_test.go b/cmd/creds_test.go index 8a08426..5b86554 100644 --- a/cmd/creds_test.go +++ b/cmd/creds_test.go @@ -38,9 +38,9 @@ func TestEnsureCreds_ForceDeletesExisting(t *testing.T) { if err != nil { t.Fatalf("ensureCreds: %v", err) } - // Should attempt to delete both secrets - if nsRunner.CallCount("delete secret") != 2 { - t.Errorf("expected 2 secret deletes, got %d: %v", + // Should attempt to delete atlassian secret + if nsRunner.CallCount("delete secret") != 1 { + t.Errorf("expected 1 secret delete, got %d: %v", nsRunner.CallCount("delete secret"), nsRunner.Calls) } } diff --git a/cmd/helpers_test.go b/cmd/helpers_test.go index 8fa0e60..1515cb8 100644 --- a/cmd/helpers_test.go +++ b/cmd/helpers_test.go @@ -65,7 +65,9 @@ func (m *mockGW) GatewayRemove(name string) error { func (m *mockGW) GatewayList() ([]gateway.GatewayInfo, error) { return m.gatewayListResult, nil } -func (m *mockGW) GatewaySelect(string) error { return nil } +func (m *mockGW) GatewaySelect(string) error { return nil } +func (m *mockGW) ProviderRefreshConfigure(string, gateway.ProviderRefreshOpts) error { return nil } +func (m *mockGW) ProviderRefreshRotate(string, string) error { return nil } func setupTestProfile(t *testing.T) string { t.Helper() diff --git a/cmd/providers.go b/cmd/providers.go index 33f51b8..ea9a3cb 100644 --- a/cmd/providers.go +++ b/cmd/providers.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "os/exec" "path/filepath" "strings" @@ -60,7 +61,7 @@ func registerProviders(harnessDir string, gw gateway.Gateway, force bool, gwCfg if len(sandboxes) > 0 { return fmt.Errorf("cannot --force with running sandboxes — delete them first") } - for _, name := range []string{"github", "vertex-local", "atlassian"} { + for _, name := range []string{"github", "vertex-local", "atlassian", "gws"} { if providerEnabled(name) { gw.ProviderDelete(name) } @@ -94,6 +95,9 @@ func registerProviders(harnessDir string, gw gateway.Gateway, force bool, gwCfg if err := registerAtlassian(gw, providerEnabled); err != nil { return err } + if err := registerGWS(gw, providerEnabled); err != nil { + return err + } // Show results status.Section("Providers") @@ -208,6 +212,72 @@ func registerAtlassian(gw gateway.Gateway, enabled func(string) bool) error { return nil } +func registerGWS(gw gateway.Gateway, enabled func(string) bool) error { + if !enabled("gws") { + status.Info("gws: disabled by gateway config") + return nil + } + if gw.ProviderGet("gws") == nil { + status.Info("gws: exists (use --force to recreate)") + return nil + } + + gwsPath, _ := exec.LookPath("gws") + if gwsPath == "" { + status.Info("gws: not installed (skipping)") + return nil + } + + out, err := exec.Command(gwsPath, "auth", "export", "--unmasked").Output() + if err != nil { + status.Info("gws: not authenticated (run 'gws auth login')") + return nil + } + + var creds struct { + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + RefreshToken string `json:"refresh_token"` + } + if err := json.Unmarshal(out, &creds); err != nil { + return fmt.Errorf("parsing gws credentials: %w", err) + } + if creds.ClientID == "" || creds.ClientSecret == "" || creds.RefreshToken == "" { + status.Info("gws: incomplete credentials (skipping)") + return nil + } + + // Create provider with a placeholder — the gateway will refresh it immediately. + if err := gw.ProviderCreate("gws", "google-workspace", gateway.ProviderCreateOpts{ + Credentials: []string{"GOOGLE_WORKSPACE_CLI_TOKEN=pending"}, + }); err != nil { + return fmt.Errorf("creating gws provider: %w", err) + } + + // Configure gateway-managed OAuth refresh. The gateway stores client_secret + // and refresh_token as secret material — they are never injected into sandboxes. + if err := gw.ProviderRefreshConfigure("gws", gateway.ProviderRefreshOpts{ + CredentialKey: "GOOGLE_WORKSPACE_CLI_TOKEN", + Strategy: "oauth2-refresh-token", + Material: []string{ + "client_id=" + creds.ClientID, + "client_secret=" + creds.ClientSecret, + "refresh_token=" + creds.RefreshToken, + }, + SecretMaterialKeys: []string{"client_secret", "refresh_token"}, + }); err != nil { + return fmt.Errorf("configuring gws refresh: %w", err) + } + + // Force an immediate refresh so the token is valid before the first sandbox. + if err := gw.ProviderRefreshRotate("gws", "GOOGLE_WORKSPACE_CLI_TOKEN"); err != nil { + status.Infof("gws: refresh rotate failed (token will refresh automatically): %v", err) + } + + status.OK("gws: registered (gateway-managed token refresh)") + return nil +} + func deleteCustomProfiles(harnessDir string, gw gateway.Gateway) { profilesDir := filepath.Join(harnessDir, "sandbox", "profiles") entries, err := os.ReadDir(profilesDir) diff --git a/cmd/teardown.go b/cmd/teardown.go index d2cffb0..7f35f51 100644 --- a/cmd/teardown.go +++ b/cmd/teardown.go @@ -149,7 +149,7 @@ func teardownK8s(gw gateway.Gateway, gwCfg *gateway.GatewayConfig, kc, clusterRu // Resolve SCC SAs and secret names from config, or use defaults sccSAs := []string{"openshell", "openshell-sandbox", "default"} sccAnyuid := []string{"openshell"} - secrets := []string{"openshell-gws", "openshell-atlassian"} + secrets := []string{"openshell-atlassian"} if gwCfg != nil { if len(gwCfg.OCP.SCCPrivileged) > 0 { sccSAs = gwCfg.OCP.SCCPrivileged diff --git a/cmd/teardown_test.go b/cmd/teardown_test.go index 3d7df00..ae8c7c3 100644 --- a/cmd/teardown_test.go +++ b/cmd/teardown_test.go @@ -47,8 +47,8 @@ func TestTeardownK8s_FullCleanup(t *testing.T) { } // Should delete secrets - if kc.CallCount("delete secret") != 2 { - t.Errorf("expected 2 secret deletes, got %d: %v", kc.CallCount("delete secret"), kc.Calls) + if kc.CallCount("delete secret") != 1 { + t.Errorf("expected 1 secret delete, got %d: %v", kc.CallCount("delete secret"), kc.Calls) } // Should delete openshell namespace diff --git a/cmd/up.go b/cmd/up.go index 17b70ae..88460c3 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -210,14 +210,12 @@ func upRemote(harnessDir string, gwCfg *gateway.GatewayConfig, gw gateway.Gatewa "env": launcherEnv(launcherEndpoint, cfg.From), "volumeMounts": []map[string]any{ {"name": "config", "mountPath": "/etc/openshell/sandbox", "readOnly": true}, - {"name": "gws", "mountPath": "/secrets/gws", "readOnly": true}, {"name": "gateway-mtls", "mountPath": "/secrets/mtls", "readOnly": true}, {"name": "sandbox-env", "mountPath": "/etc/openshell/env", "readOnly": true}, }, }}, "volumes": []map[string]any{ {"name": "config", "configMap": map[string]any{"name": "sandbox-" + cfg.Name}}, - {"name": "gws", "secret": map[string]any{"secretName": "openshell-gws", "optional": true}}, {"name": "gateway-mtls", "secret": map[string]any{"secretName": mtlsSecret}}, {"name": "sandbox-env", "configMap": map[string]any{"name": "sandbox-" + cfg.Name + "-env", "optional": true}}, }, diff --git a/gateways/ocp/gateway.toml b/gateways/ocp/gateway.toml index cbf6b62..a815042 100644 --- a/gateways/ocp/gateway.toml +++ b/gateways/ocp/gateway.toml @@ -17,8 +17,7 @@ name = "openshell-remote-ocp" mode = "launcher" [providers] -enabled = ["github", "vertex-local", "atlassian"] -custom = ["gws"] +enabled = ["github", "vertex-local", "atlassian", "gws"] [chart] oci = "oci://ghcr.io/nvidia/openshell/helm-chart" @@ -40,7 +39,7 @@ scc-privileged = ["openshell", "openshell-sandbox"] scc-anyuid = ["openshell"] [secrets] -names = ["openshell-gws", "openshell-atlassian"] +names = ["openshell-atlassian"] mtls = "openshell-client-tls" [launcher] diff --git a/internal/gateway/cli.go b/internal/gateway/cli.go index 5775b96..07d7e62 100644 --- a/internal/gateway/cli.go +++ b/internal/gateway/cli.go @@ -99,6 +99,24 @@ func (c *CLI) ProviderProfileImport(dir string) error { return c.silent("provider", "profile", "import", "--from", dir) } +func (c *CLI) ProviderRefreshConfigure(name string, opts ProviderRefreshOpts) error { + args := []string{"provider", "refresh", "configure", name, + "--credential-key", opts.CredentialKey, + "--strategy", opts.Strategy, + } + for _, m := range opts.Material { + args = append(args, "--material", m) + } + for _, k := range opts.SecretMaterialKeys { + args = append(args, "--secret-material-key", k) + } + return c.passthrough(args...) +} + +func (c *CLI) ProviderRefreshRotate(name, credentialKey string) error { + return c.silent("provider", "refresh", "rotate", name, "--credential-key", credentialKey) +} + func (c *CLI) ProviderProfileDelete(id string) error { return c.silent("provider", "profile", "delete", id) } diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 3be82e2..ee2b43e 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -9,6 +9,8 @@ type Gateway interface { ProviderList() ([]string, error) ProviderProfileImport(dir string) error ProviderProfileDelete(id string) error + ProviderRefreshConfigure(name string, opts ProviderRefreshOpts) error + ProviderRefreshRotate(name, credentialKey string) error // Sandboxes SandboxList() ([]string, error) @@ -39,6 +41,13 @@ type ProviderCreateOpts struct { FromADC bool } +type ProviderRefreshOpts struct { + CredentialKey string + Strategy string + Material []string // KEY=VALUE pairs + SecretMaterialKeys []string // keys within Material that are secret +} + type GatewayInfo struct { Name string Endpoint string diff --git a/internal/profile/profile.go b/internal/profile/profile.go index 1e15e41..e990b60 100644 --- a/internal/profile/profile.go +++ b/internal/profile/profile.go @@ -2,9 +2,7 @@ package profile import ( "fmt" - "io" "os" - "os/exec" "path/filepath" "sort" "strings" @@ -84,7 +82,7 @@ func ValidateProviders(providers []string, gw ProviderChecker) (registered, miss return } -// StageHarnessDir writes sandbox.env and copies GWS credentials to harnessDir. +// StageHarnessDir writes sandbox.env to harnessDir. func StageHarnessDir(cfg *Config, harnessDir string) error { if err := os.MkdirAll(harnessDir, 0o755); err != nil { return err @@ -99,45 +97,5 @@ func StageHarnessDir(cfg *Config, harnessDir string) error { fmt.Printf(" Env: %d vars staged\n", lines) } - if err := stageGWSCreds(harnessDir); err != nil { - fmt.Printf(" GWS: %v\n", err) - } - return nil -} - -func stageGWSCreds(harnessDir string) error { - gwsPath, err := exec.LookPath("gws") - if err != nil { - return fmt.Errorf("not installed (skipping)") - } - - check := exec.Command(gwsPath, "auth", "status") - check.Stdout = io.Discard - check.Stderr = io.Discard - if check.Run() != nil { - return fmt.Errorf("not authenticated (skipping)") - } - - out, err := exec.Command(gwsPath, "auth", "export", "--unmasked").Output() - if err != nil { - return fmt.Errorf("export failed (skipping)") - } - if err := os.WriteFile(filepath.Join(harnessDir, "credentials.json"), out, 0o600); err != nil { - return err - } - - gwsConfigDir := os.Getenv("GWS_CONFIG_DIR") - if gwsConfigDir == "" { - home, _ := os.UserHomeDir() - gwsConfigDir = filepath.Join(home, ".config", "gws") - } - clientSecret := filepath.Join(gwsConfigDir, "client_secret.json") - if data, err := os.ReadFile(clientSecret); err == nil { - if err := os.WriteFile(filepath.Join(harnessDir, "client_secret.json"), data, 0o600); err != nil { - return fmt.Errorf("writing client_secret.json: %w", err) - } - } - - fmt.Println(" GWS: exported") return nil } diff --git a/openshell.toml b/openshell.toml index 9f6a998..b0f4ce9 100644 --- a/openshell.toml +++ b/openshell.toml @@ -8,12 +8,12 @@ # ./openshell-harness-preflight.sh # Which providers to register and attach to sandboxes. -# Must match names defined in providers.toml. -providers = ["github", "vertex-local", "atlassian"] +# Must match names defined in providers.toml or imported provider profiles. +providers = ["github", "vertex-local", "atlassian", "gws"] -# Custom providers to enable (from [providers-custom] in providers.toml). -# These will eventually become regular [providers] when OpenShell adds native support. -providers-custom = ["gws"] +# Fallback: move gws back to providers-custom to use legacy file staging. +# providers = ["github", "vertex-local", "atlassian"] +# providers-custom = ["gws"] [inference] model = "claude-sonnet-4-6" diff --git a/profiles/default.toml b/profiles/default.toml index b864f48..50b9387 100644 --- a/profiles/default.toml +++ b/profiles/default.toml @@ -10,11 +10,9 @@ command = "claude --bare" startup = "/sandbox/startup.sh" keep = true -providers = ["github", "vertex-local", "atlassian"] +providers = ["github", "vertex-local", "atlassian", "gws"] [env] ANTHROPIC_BASE_URL = "https://inference.local" ANTHROPIC_API_KEY = "sk-ant-openshell-proxy-managed" CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS = "1" -GOOGLE_WORKSPACE_CLI_CONFIG_DIR = "/sandbox/.config/openshell" -GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE = "/sandbox/.config/openshell/credentials.json" diff --git a/sandbox/launcher/main.go b/sandbox/launcher/main.go index 47c3708..561814f 100644 --- a/sandbox/launcher/main.go +++ b/sandbox/launcher/main.go @@ -129,7 +129,7 @@ func checkProviders(providers []string, cli string) []string { var stageFilesFrom = "/etc/openshell/env/sandbox.env" -func stageFiles(cfg *Config, gwsDir, harnessDir string) error { +func stageFiles(harnessDir string) error { if err := os.MkdirAll(harnessDir, 0o755); err != nil { return err } @@ -143,24 +143,6 @@ func stageFiles(cfg *Config, gwsDir, harnessDir string) error { fmt.Printf(" Env: %d vars\n", lines) } - if fileExists(filepath.Join(gwsDir, "credentials.json")) { - entries, err := os.ReadDir(gwsDir) - if err != nil { - return fmt.Errorf("reading gws dir: %w", err) - } - for _, e := range entries { - if !e.IsDir() && !strings.HasPrefix(e.Name(), "..") { - src := filepath.Join(gwsDir, e.Name()) - dst := filepath.Join(harnessDir, e.Name()) - if err := copyFile(src, dst); err != nil { - return fmt.Errorf("copying %s: %w", e.Name(), err) - } - } - } - fmt.Println(" GWS: staged") - } else { - fmt.Println(" GWS: not mounted (skipping)") - } return nil } @@ -282,7 +264,7 @@ func main() { providers := checkProviders(cfg.Providers, cli) harnessDir := "/tmp/openshell" - if err := stageFiles(cfg, "/secrets/gws", harnessDir); err != nil { + if err := stageFiles(harnessDir); err != nil { fmt.Fprintf(os.Stderr, "ERROR: staging files: %v\n", err) os.Exit(1) } diff --git a/sandbox/launcher/main_test.go b/sandbox/launcher/main_test.go index b9458ab..d2b8ec0 100644 --- a/sandbox/launcher/main_test.go +++ b/sandbox/launcher/main_test.go @@ -96,11 +96,9 @@ func TestParseConfig_Invalid(t *testing.T) { func TestStageFiles_EnvFromFile(t *testing.T) { dir := t.TempDir() - gwsDir := filepath.Join(dir, "gws") harnessDir := filepath.Join(dir, "harness") envDir := filepath.Join(dir, "env") - os.MkdirAll(gwsDir, 0o755) os.MkdirAll(envDir, 0o755) envContent := "export FOO=bar\nexport BAZ=qux\n" @@ -111,8 +109,7 @@ func TestStageFiles_EnvFromFile(t *testing.T) { stageFilesFrom = envFile defer func() { stageFilesFrom = origStageFiles }() - cfg := &Config{Env: map[string]string{"FOO": "bar", "BAZ": "qux"}} - if err := stageFiles(cfg, gwsDir, harnessDir); err != nil { + if err := stageFiles(harnessDir); err != nil { t.Fatalf("stageFiles: %v", err) } @@ -125,57 +122,19 @@ func TestStageFiles_EnvFromFile(t *testing.T) { } } -func TestStageFiles_GWSCreds(t *testing.T) { +func TestStageFiles_NoEnv(t *testing.T) { dir := t.TempDir() - gwsDir := filepath.Join(dir, "gws") harnessDir := filepath.Join(dir, "harness") - os.MkdirAll(gwsDir, 0o755) - os.WriteFile(filepath.Join(gwsDir, "credentials.json"), []byte(`{"token":"abc"}`), 0o644) - os.WriteFile(filepath.Join(gwsDir, "client_secret.json"), []byte(`{"secret":"xyz"}`), 0o644) - - origStageFiles := stageFilesFrom - stageFilesFrom = "/nonexistent/sandbox.env" - defer func() { stageFilesFrom = origStageFiles }() - - cfg := &Config{} - if err := stageFiles(cfg, gwsDir, harnessDir); err != nil { - t.Fatalf("stageFiles: %v", err) - } - - data, err := os.ReadFile(filepath.Join(harnessDir, "credentials.json")) - if err != nil { - t.Fatalf("reading credentials.json: %v", err) - } - if string(data) != `{"token":"abc"}` { - t.Errorf("credentials.json = %q", string(data)) - } - - data, err = os.ReadFile(filepath.Join(harnessDir, "client_secret.json")) - if err != nil { - t.Fatalf("reading client_secret.json: %v", err) - } - if string(data) != `{"secret":"xyz"}` { - t.Errorf("client_secret.json = %q", string(data)) - } -} - -func TestStageFiles_NoGWS(t *testing.T) { - dir := t.TempDir() - gwsDir := filepath.Join(dir, "gws") - harnessDir := filepath.Join(dir, "harness") - os.MkdirAll(gwsDir, 0o755) - origStageFiles := stageFilesFrom stageFilesFrom = "/nonexistent/sandbox.env" defer func() { stageFilesFrom = origStageFiles }() - cfg := &Config{} - if err := stageFiles(cfg, gwsDir, harnessDir); err != nil { + if err := stageFiles(harnessDir); err != nil { t.Fatalf("stageFiles: %v", err) } - if _, err := os.Stat(filepath.Join(harnessDir, "credentials.json")); err == nil { - t.Error("credentials.json should not exist when GWS not mounted") + if _, err := os.Stat(filepath.Join(harnessDir, "sandbox.env")); err == nil { + t.Error("sandbox.env should not exist when env file not present") } } diff --git a/sandbox/profiles/atlassian-user.yaml b/sandbox/profiles/atlassian-user.yaml new file mode 100644 index 0000000..ca6e2ba --- /dev/null +++ b/sandbox/profiles/atlassian-user.yaml @@ -0,0 +1,39 @@ +# Atlassian provider profile with user-scoped config. +# +# JIRA_API_TOKEN is a credential (proxy-resolved, never visible in sandbox). +# JIRA_URL and JIRA_USERNAME are configs (non-secret, injected as env vars). + +id: atlassian-user +display_name: Atlassian (Jira + Confluence) +description: Jira and Confluence via mcp-atlassian MCP server +category: knowledge +credentials: + - name: api_token + description: Atlassian API token + env_vars: [JIRA_API_TOKEN] + required: true + auth_style: basic +configs: + - name: jira_url + description: Jira instance URL (e.g. https://mysite.atlassian.net) + env_vars: [JIRA_URL] + required: true + - name: jira_username + description: Jira username (email address) + env_vars: [JIRA_USERNAME] + required: true +discovery: + credentials: [api_token] + configs: [jira_url, jira_username] +endpoints: + - host: "*.atlassian.net" + port: 443 + - host: "*.atl-paas.net" + port: 443 + - host: "*.atlassian.com" + port: 443 +binaries: + - /sandbox/.venv/bin/python + - /sandbox/.venv/bin/python3 + - /sandbox/.uv/python/** + - /sandbox/.venv/bin/mcp-atlassian diff --git a/sandbox/profiles/gws.yaml b/sandbox/profiles/gws.yaml new file mode 100644 index 0000000..5cb97bc --- /dev/null +++ b/sandbox/profiles/gws.yaml @@ -0,0 +1,99 @@ +# Google Workspace provider profile. +# +# Credentials (client_id, client_secret, refresh_token) are stored in the +# gateway and injected into the sandbox as proxy-resolved placeholders. +# The oauth2.googleapis.com endpoint has request_body_credential_rewrite +# enabled, so when gws CLI sends a token refresh POST, the proxy resolves +# the placeholders in the form-urlencoded body before forwarding to Google. +# The sandbox never sees the real credentials. +# +# Registration: +# openshell provider create --name gws --type google-workspace \ +# --credential client_id=... \ +# --credential client_secret=... \ +# --credential refresh_token=... + +id: google-workspace +display_name: Google Workspace +description: Gmail, Calendar, Drive, Tasks via gws CLI +category: knowledge +credentials: + - name: GOOGLE_WORKSPACE_CLI_TOKEN + description: Google OAuth2 access token — gateway-refreshed, never exposed to sandbox + env_vars: [GOOGLE_WORKSPACE_CLI_TOKEN] + required: true + auth_style: bearer + header_name: authorization + refresh: + strategy: oauth2_refresh_token + token_url: https://oauth2.googleapis.com/token + scopes: + - https://www.googleapis.com/auth/gmail.readonly + - https://www.googleapis.com/auth/calendar.readonly + - https://www.googleapis.com/auth/drive.readonly + - https://www.googleapis.com/auth/tasks + refresh_before_seconds: 300 + max_lifetime_seconds: 3600 + material: + - name: client_id + description: Google OAuth2 client ID + required: true + secret: false + - name: client_secret + description: Google OAuth2 client secret + required: true + secret: true + - name: refresh_token + description: Google OAuth2 refresh token + required: true + secret: true +discovery: + credentials: + - GOOGLE_WORKSPACE_CLI_TOKEN +endpoints: + - host: gmail.googleapis.com + port: 443 + protocol: rest + access: read-only + enforcement: enforce + - host: calendar-json.googleapis.com + port: 443 + protocol: rest + access: read-only + enforcement: enforce + - host: drive.googleapis.com + port: 443 + protocol: rest + access: read-only + enforcement: enforce + - host: docs.googleapis.com + port: 443 + protocol: rest + access: read-only + enforcement: enforce + - host: sheets.googleapis.com + port: 443 + protocol: rest + access: read-only + enforcement: enforce + - host: tasks.googleapis.com + port: 443 + protocol: rest + access: read-write + enforcement: enforce + - host: oauth2.googleapis.com + port: 443 + protocol: rest + access: read-write + enforcement: enforce + request_body_credential_rewrite: true + - host: www.googleapis.com + port: 443 + protocol: rest + access: read-only + enforcement: enforce +binaries: + - /usr/local/bin/gws + - /sandbox/.local/bin/gws + - /usr/bin/curl + - /usr/local/bin/curl From 793d450963e3777fda644e415079c7b7055d70a4 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Sun, 7 Jun 2026 02:33:42 -0700 Subject: [PATCH 03/11] feat: validate-local/kind targets with GWS lifecycle test and kind deploy Implement kind cluster deployment (NodePort + HTTP, disableTls=true) and add validate-local/validate-kind Makefile targets that run unit tests, bats, and full integration including a GWS token lifecycle test. Kind deploy flow: - NodePort service with plaintext HTTP (disableTls: true, pkiInitJob still runs for JWT signing keys) - Node IP resolved via kubectl get nodes after Helm rollout completes - http:// URL skips mTLS and browser auth automatically - No cert extraction needed (direct mode, allowUnauthenticatedUsers: true) - All four providers (github, vertex-local, atlassian, gws) enabled GWS verification in sandbox_verify(): - Checks GOOGLE_WORKSPACE_CLI_TOKEN is a proxy placeholder (not real token) - Makes a live Gmail API call through the proxy to confirm resolution works - test_gws() adds forced rotation + post-rotation API call k8s.Runner interface: GetServiceNodePort and GetNodeInternalIP added. Gateway.GatewayAdd: insecure bool parameter added (currently unused, kept for future https+insecure path). Validated: harness deploy kind, harness providers, kind sandbox with gws provider all work end-to-end on macOS Docker Desktop kind cluster. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- Makefile | 33 ++++++++++++-- cmd/deploy.go | 71 ++++++++++++++++++----------- cmd/deploy_test.go | 12 +++-- cmd/helpers_test.go | 2 +- gateways/kind/gateway.toml | 14 +++--- gateways/kind/helm/values.yaml | 13 +++++- internal/gateway/cli.go | 5 ++- internal/gateway/cli_test.go | 2 +- internal/gateway/gateway.go | 2 +- internal/k8s/kubectl.go | 34 ++++++++++++++ internal/k8s/mock.go | 26 +++++++++++ test/test-flow.sh | 82 +++++++++++++++++++++++++++++++--- 12 files changed, 247 insertions(+), 49 deletions(-) diff --git a/Makefile b/Makefile index be0f0e8..1e2f9dd 100644 --- a/Makefile +++ b/Makefile @@ -18,8 +18,8 @@ DEV_SANDBOX_IMAGE := $(DEV_REGISTRY):$(DEV_TAG)-sandbox DEV_LAUNCHER_IMAGE := $(DEV_REGISTRY):$(DEV_TAG)-launcher .PHONY: cli sandbox push-sandbox cli-launcher launcher push-launcher \ - vet lint test-unit test test-local test-ocp test-all validate \ - dev-sandbox dev-launcher validate-dev clean help + vet lint test-unit test test-local test-kind test-ocp test-all validate \ + validate-local validate-kind dev-sandbox dev-launcher validate-dev clean help ## ── CLI ────────────────────────────────────────────────────────────── @@ -84,11 +84,15 @@ test: cli sandbox push-launcher test-local: cli ./test/test-flow.sh local --full +## kind cluster (full lifecycle — requires: kind create cluster --name openshell) +test-kind: cli + ./test/test-flow.sh kind --full + ## OCP only (full lifecycle) test-ocp: cli sandbox push-launcher ./test/test-flow.sh ocp --full -## All combinations: podman + ocp +## All combinations: local + kind + ocp test-all: cli sandbox push-launcher ./test/test-flow.sh all --full @@ -147,6 +151,29 @@ validate-dev: cli dev-sandbox dev-launcher @echo "=== Integration: OCP (full) ===" SANDBOX_IMAGE=$(DEV_SANDBOX_IMAGE) LAUNCHER_IMAGE=$(DEV_LAUNCHER_IMAGE) ./test/test-flow.sh ocp --full +## Local validate: unit tests + bats + full local integration with GWS lifecycle test. +## Requires: openshell gateway running locally (brew services start openshell). +validate-local: cli + @echo "=== Unit tests ===" + CGO_ENABLED=0 go test ./... + cd sandbox/launcher && go test ./... + @echo "" + @echo "=== Bats ===" + bats test/preflight.bats + @echo "" + @echo "=== Integration: local (full, with GWS) ===" + ./test/test-flow.sh local --full + +## kind validate: unit tests + full kind integration with GWS lifecycle test. +## Requires: kind create cluster --name openshell +validate-kind: cli + @echo "=== Unit tests ===" + CGO_ENABLED=0 go test ./... + cd sandbox/launcher && go test ./... + @echo "" + @echo "=== Integration: kind (full, with GWS) ===" + ./test/test-flow.sh kind --full + ## ── Convenience targets ─────────────────────────────────────────────── ## Clean built binaries diff --git a/cmd/deploy.go b/cmd/deploy.go index 1b609c0..b3f36d7 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -195,21 +195,15 @@ func deployFromConfig(harnessDir string, gwCfg *gateway.GatewayConfig, gw gatewa // Step 4: Helm install status.Step(4, "Deploying gateway via Helm") - var gatewayURL string + // routeHost is needed before Helm (for OCP PKI cert SAN). + // gatewayURL is resolved after Helm for nodeport (service doesn't exist yet). var routeHost string - - switch gwCfg.Gateway.Service { - case "route": + if gwCfg.Gateway.Service == "route" { appsDomain, err := clusterRunner.GetJSONPath(ctx, "ingresses.config.openshift.io/cluster", "{.spec.domain}") if err != nil || appsDomain == "" { return fmt.Errorf("could not determine OpenShift apps domain — is this an OpenShift cluster? (kubectl get ingresses.config.openshift.io cluster)") } routeHost = fmt.Sprintf("gateway-openshell.%s", appsDomain) - gatewayURL = fmt.Sprintf("https://%s:443", routeHost) - case "nodeport": - gatewayURL = "" - case "loadbalancer": - gatewayURL = "" } helmArgs := []string{ @@ -241,11 +235,28 @@ func deployFromConfig(harnessDir string, gwCfg *gateway.GatewayConfig, gw gatewa } // Step 5: CLI gateway config + // Resolve the gateway URL now that the service exists. status.Step(5, "Configuring CLI gateway") gatewayName := gwCfg.Gateway.Name - if gatewayURL == "" { - return fmt.Errorf("service type %q endpoint resolution not yet implemented", gwCfg.Gateway.Service) + var gatewayURL string + switch gwCfg.Gateway.Service { + case "route": + gatewayURL = fmt.Sprintf("https://%s:443", routeHost) + case "nodeport": + nodePort, err := kc.GetServiceNodePort(ctx, "openshell", 8080) + if err != nil { + return fmt.Errorf("getting NodePort: %w", err) + } + nodeIP, err := clusterRunner.GetNodeInternalIP(ctx) + if err != nil { + return fmt.Errorf("getting node IP: %w", err) + } + // Use HTTP — kind gateway runs with disableTls=true so the CLI + // registers plaintext, skipping mTLS and browser auth entirely. + gatewayURL = fmt.Sprintf("http://%s:%d", nodeIP, nodePort) + case "loadbalancer": + return fmt.Errorf("loadbalancer endpoint resolution not yet implemented") } existing, err := gw.GatewayList() @@ -258,32 +269,40 @@ func deployFromConfig(harnessDir string, gwCfg *gateway.GatewayConfig, gw gatewa } } - if err := gw.GatewayAdd(gatewayURL, gatewayName, true); err != nil { + if err := gw.GatewayAdd(gatewayURL, gatewayName, true, false); err != nil { return fmt.Errorf("registering gateway %s: %w", gatewayName, err) } - home, err := os.UserHomeDir() - if err != nil { - return fmt.Errorf("determining home directory: %w", err) - } - mtlsDir := filepath.Join(home, ".config", "openshell", "gateways", gatewayName, "mtls") - if err := os.MkdirAll(mtlsDir, 0o700); err != nil { - return fmt.Errorf("creating mtls directory: %w", err) - } - for _, field := range []string{"ca.crt", "tls.crt", "tls.key"} { - data, err := kc.GetSecretField(ctx, gwCfg.Secrets.MTLS, field) + // mTLS cert extraction — only needed for launcher mode (OCP/remote clusters). + // Direct mode (kind, plain k8s) uses HTTP or skips client cert auth. + if gwCfg.UsesLauncher() && gwCfg.Secrets.MTLS != "" { + home, err := os.UserHomeDir() if err != nil { - return fmt.Errorf("extracting %s from %s: %w", field, gwCfg.Secrets.MTLS, err) + return fmt.Errorf("determining home directory: %w", err) } - if err := os.WriteFile(filepath.Join(mtlsDir, field), data, 0o600); err != nil { - return fmt.Errorf("writing %s: %w", field, err) + mtlsDir := filepath.Join(home, ".config", "openshell", "gateways", gatewayName, "mtls") + if err := os.MkdirAll(mtlsDir, 0o700); err != nil { + return fmt.Errorf("creating mtls directory: %w", err) + } + for _, field := range []string{"ca.crt", "tls.crt", "tls.key"} { + data, err := kc.GetSecretField(ctx, gwCfg.Secrets.MTLS, field) + if err != nil { + return fmt.Errorf("extracting %s from %s: %w", field, gwCfg.Secrets.MTLS, err) + } + if err := os.WriteFile(filepath.Join(mtlsDir, field), data, 0o600); err != nil { + return fmt.Errorf("writing %s: %w", field, err) + } } } if err := gw.GatewaySelect(gatewayName); err != nil { return fmt.Errorf("selecting gateway %s: %w", gatewayName, err) } - status.OKf("%s registered (certs from cluster)", gatewayName) + if gwCfg.UsesLauncher() { + status.OKf("%s registered (certs from cluster)", gatewayName) + } else { + status.OKf("%s registered", gatewayName) + } fmt.Print(" Waiting for gateway...") var gwReachable bool diff --git a/cmd/deploy_test.go b/cmd/deploy_test.go index 1083719..04ea332 100644 --- a/cmd/deploy_test.go +++ b/cmd/deploy_test.go @@ -141,6 +141,7 @@ func TestDeployFromConfig_K8s_NoSCCs(t *testing.T) { gwDir := setupK8sGatewayConfig(t, dir) t.Setenv("OPENSHELL_CHART_VERSION", "0.0.55") t.Setenv("OPENSHELL_NAMESPACE", "openshell") + t.Setenv("HOME", t.TempDir()) gwCfg, err := gateway.LoadConfig(gwDir) if err != nil { @@ -152,16 +153,21 @@ func TestDeployFromConfig_K8s_NoSCCs(t *testing.T) { gw := &mockGW{} - // NodePort endpoint resolution not implemented — expect that specific error + // NodePort deploy should succeed — mock returns default node IP + port err = deployFromConfig(dir, gwCfg, gw, nsRunner, clusterRunner) - if err == nil { - t.Fatal("expected error for unimplemented nodeport") + if err != nil { + t.Fatalf("deployFromConfig: %v", err) } // Verify NO OC/SCC calls were made (k8s, not OCP) if nsRunner.HasCall("oc adm") { t.Errorf("should not run oc commands on k8s platform, calls: %v", nsRunner.Calls) } + + // Verify NO mTLS cert extraction (direct mode, no launcher) + if nsRunner.HasCall("get-secret-field") { + t.Errorf("should not extract mTLS certs for direct-mode k8s, calls: %v", nsRunner.Calls) + } } func TestDeployFromConfig_HelmFailure(t *testing.T) { diff --git a/cmd/helpers_test.go b/cmd/helpers_test.go index 1515cb8..2d76ebc 100644 --- a/cmd/helpers_test.go +++ b/cmd/helpers_test.go @@ -55,7 +55,7 @@ func (m *mockGW) ProviderProfileDelete(string) error func (m *mockGW) SettingsSet(string, string) error { return nil } func (m *mockGW) SandboxList() ([]string, error) { return nil, nil } func (m *mockGW) SandboxConnect(string) error { return nil } -func (m *mockGW) GatewayAdd(string, string, bool) error { return nil } +func (m *mockGW) GatewayAdd(string, string, bool, bool) error { return nil } func (m *mockGW) GatewayRemove(name string) error { if m.onGatewayRemove != nil { m.onGatewayRemove(name) diff --git a/gateways/kind/gateway.toml b/gateways/kind/gateway.toml index c3c1051..03dbb7b 100644 --- a/gateways/kind/gateway.toml +++ b/gateways/kind/gateway.toml @@ -1,11 +1,13 @@ # kind gateway — deploys openshell to a local kind cluster. # # Prerequisites: -# - kind installed -# - Cluster created: kind create cluster --name openshell +# - kind installed and cluster running: +# kind create cluster --name openshell +# - kubectl context set to the kind cluster # -# Uses NodePort for access (no Ingress controller needed). -# Direct mode: no launcher Job, official providers only. +# Uses NodePort + node InternalIP for access (no Ingress controller needed). +# Direct mode: no launcher Job, providers registered from workstation. +# No mTLS: the gateway runs HTTP for local dev simplicity. [gateway] type = "remote" @@ -15,7 +17,7 @@ name = "openshell-kind" mode = "direct" [providers] -enabled = ["github", "vertex-local"] +enabled = ["github", "vertex-local", "atlassian", "gws"] [chart] oci = "oci://ghcr.io/nvidia/openshell/helm-chart" @@ -23,3 +25,5 @@ version = "0.0.55" [chart.crd] url = "https://github.com/kubernetes-sigs/agent-sandbox/releases/latest/download/manifest.yaml" +[helm] +values = "values.yaml" diff --git a/gateways/kind/helm/values.yaml b/gateways/kind/helm/values.yaml index d01ce73..4d5b208 100644 --- a/gateways/kind/helm/values.yaml +++ b/gateways/kind/helm/values.yaml @@ -1,11 +1,20 @@ # kind gateway Helm values. # -# Chart defaults are vanilla-k8s-friendly — this file only overrides -# what's needed for kind specifically. +# NodePort service, plaintext HTTP (no TLS) for local dev. +# The openshell CLI registers the gateway via http:// which skips mTLS and +# browser auth entirely. The PKI init job still runs to create the JWT +# signing key secret the gateway requires. service: type: NodePort server: + disableTls: true auth: allowUnauthenticatedUsers: true + +# PKI init job creates the JWT signing key (openshell-jwt-keys secret). +# Even with TLS disabled, the gateway needs the JWT signing key to +# issue sandbox tokens. +pkiInitJob: + enabled: true diff --git a/internal/gateway/cli.go b/internal/gateway/cli.go index 07d7e62..fe5a7cd 100644 --- a/internal/gateway/cli.go +++ b/internal/gateway/cli.go @@ -181,11 +181,14 @@ func (c *CLI) GatewayList() ([]GatewayInfo, error) { return gateways, nil } -func (c *CLI) GatewayAdd(endpoint, name string, local bool) error { +func (c *CLI) GatewayAdd(endpoint, name string, local, insecure bool) error { args := []string{"gateway", "add", endpoint, "--name", name} if local { args = append(args, "--local") } + if insecure { + args = append(args, "--insecure") + } return c.silent(args...) } diff --git a/internal/gateway/cli_test.go b/internal/gateway/cli_test.go index 5e17fb1..13b6214 100644 --- a/internal/gateway/cli_test.go +++ b/internal/gateway/cli_test.go @@ -366,7 +366,7 @@ func TestGatewayAdd_Args(t *testing.T) { printf '%s\n' "$*" > `+argsFile+` `) gw := New(bin) - gw.GatewayAdd("https://gw.example.com:443", "my-ocp", true) + gw.GatewayAdd("https://gw.example.com:443", "my-ocp", true, false) data, _ := os.ReadFile(argsFile) args := strings.TrimSpace(string(data)) for _, want := range []string{ diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index ee2b43e..989c7b7 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -28,7 +28,7 @@ type Gateway interface { CLIVersion() string CLIPath() string ActiveGateway() string - GatewayAdd(endpoint, name string, local bool) error + GatewayAdd(endpoint, name string, local, insecure bool) error GatewayRemove(name string) error GatewayList() ([]GatewayInfo, error) GatewaySelect(name string) error diff --git a/internal/k8s/kubectl.go b/internal/k8s/kubectl.go index 654c65f..d6433b5 100644 --- a/internal/k8s/kubectl.go +++ b/internal/k8s/kubectl.go @@ -39,6 +39,8 @@ type Runner interface { GetSecretField(ctx context.Context, secretName, field string) ([]byte, error) GetJSONPath(ctx context.Context, resource, jsonpath string) (string, error) NamespaceExists(ctx context.Context, ns string) bool + GetServiceNodePort(ctx context.Context, svcName string, containerPort int) (int, error) + GetNodeInternalIP(ctx context.Context) (string, error) } type Client struct { @@ -195,6 +197,38 @@ func (c *Client) NamespaceExists(ctx context.Context, ns string) bool { return c.RunKubectlQuiet(ctx, "get", "ns", ns) == nil } +func (c *Client) GetServiceNodePort(ctx context.Context, svcName string, containerPort int) (int, error) { + jsonpath := fmt.Sprintf( + `{.spec.ports[?(@.port==%d)].nodePort}`, containerPort) + out, err := c.RunKubectl(ctx, "get", "svc", svcName, "-o", "jsonpath="+jsonpath) + if err != nil { + return 0, fmt.Errorf("getting NodePort for %s:%d: %w", svcName, containerPort, err) + } + out = strings.TrimSpace(out) + if out == "" { + return 0, fmt.Errorf("no NodePort found for %s port %d", svcName, containerPort) + } + var port int + if _, err := fmt.Sscanf(out, "%d", &port); err != nil { + return 0, fmt.Errorf("parsing NodePort %q: %w", out, err) + } + return port, nil +} + +func (c *Client) GetNodeInternalIP(ctx context.Context) (string, error) { + // Use cluster-runner (no namespace) so skip namespace injection + out, err := c.RunKubectl(ctx, "get", "nodes", + "-o", `jsonpath={.items[0].status.addresses[?(@.type=="InternalIP")].address}`) + if err != nil { + return "", fmt.Errorf("getting node IP: %w", err) + } + ip := strings.TrimSpace(out) + if ip == "" { + return "", fmt.Errorf("no InternalIP found on any node") + } + return ip, nil +} + func isTransient(output string) bool { lower := strings.ToLower(output) for _, pattern := range transientErrors { diff --git a/internal/k8s/mock.go b/internal/k8s/mock.go index 4c1cf16..b594f9e 100644 --- a/internal/k8s/mock.go +++ b/internal/k8s/mock.go @@ -101,6 +101,32 @@ func (m *MockRunner) NamespaceExists(_ context.Context, ns string) bool { return err == nil } +func (m *MockRunner) GetServiceNodePort(_ context.Context, svcName string, containerPort int) (int, error) { + call := m.record(fmt.Sprintf("get-nodeport %s %d", svcName, containerPort)) + resp, err := m.respond(call) + if err != nil { + return 0, err + } + if resp == "" { + return 30080, nil // default test NodePort + } + var port int + fmt.Sscanf(resp, "%d", &port) + return port, nil +} + +func (m *MockRunner) GetNodeInternalIP(_ context.Context) (string, error) { + call := m.record("get-node-ip") + resp, err := m.respond(call) + if err != nil { + return "", err + } + if resp == "" { + return "172.18.0.2", nil // default test node IP + } + return resp, nil +} + // HasCall checks if any recorded call starts with the given prefix. func (m *MockRunner) HasCall(prefix string) bool { for _, c := range m.Calls { diff --git a/test/test-flow.sh b/test/test-flow.sh index b975bce..0c38912 100755 --- a/test/test-flow.sh +++ b/test/test-flow.sh @@ -1,13 +1,14 @@ #!/usr/bin/env bash -# End-to-end validation for local and OCP flows. +# End-to-end validation for local, kind, and OCP flows. # # Usage: # ./test-flow.sh local # quick: deploy + providers + teardown # ./test-flow.sh local --full # full: + sandbox + verify integrations # ./test-flow.sh local --full --no-providers --profile=ci # CI mode (no creds) +# ./test-flow.sh kind [--full] # kind cluster variants # ./test-flow.sh ocp [--full] # OCP variants # ./test-flow.sh ocp --full --reuse-gateway # skip deploy/teardown-k8s (~50s vs ~130s) -# ./test-flow.sh all [--full] # both platforms +# ./test-flow.sh all [--full] # all platforms set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" @@ -39,7 +40,7 @@ for arg in "$@"; do done if [[ -z "$TARGET" ]]; then - echo "Usage: $0 [--full] [--reuse-gateway]" + echo "Usage: $0 [--full] [--reuse-gateway]" exit 1 fi @@ -131,7 +132,8 @@ sandbox_verify() { # Provider-dependent checks (require credentials + inference) step "sandbox: env vars" "$CLI" sandbox exec --name "$name" -- bash -c 'test -n "$ANTHROPIC_BASE_URL"' - step "sandbox: gws creds" "$CLI" sandbox exec --name "$name" -- test -f /sandbox/.config/openshell/credentials.json + step "sandbox: gws token placeholder" "$CLI" sandbox exec --name "$name" -- bash -c 'echo "$GOOGLE_WORKSPACE_CLI_TOKEN" | grep -q "openshell:resolve:env"' + step "sandbox: gws api call" "$CLI" sandbox exec --name "$name" -- bash -c 'curl -sf https://gmail.googleapis.com/gmail/v1/users/me/profile -H "Authorization: Bearer $GOOGLE_WORKSPACE_CLI_TOKEN" -o /dev/null' step "sandbox: mcp config" "$CLI" sandbox exec --name "$name" -- test -f /sandbox/.mcp.json step_output "sandbox: claude responds" "$CLI" sandbox exec --name "$name" -- bash -c 'echo "respond with ok" | claude --bare --print 2>&1 | head -1' } @@ -211,6 +213,73 @@ test_local() { step "teardown (clean)" "$HARNESS" teardown --sandboxes --providers } +# ── GWS lifecycle test ─────────────────────────────────────────────── + +test_gws() { + local sandbox_name="$1" + echo "=== test: GWS token lifecycle ===" + + # Token is a proxy placeholder, never a real token + step "gws: token is placeholder" \ + "$CLI" sandbox exec --name "$sandbox_name" -- bash -c \ + 'echo "$GOOGLE_WORKSPACE_CLI_TOKEN" | grep -q "openshell:resolve:env"' + + # Real API call works through proxy (token resolved on the wire) + step "gws: Gmail API via proxy" \ + "$CLI" sandbox exec --name "$sandbox_name" -- bash -c \ + 'curl -sf https://gmail.googleapis.com/gmail/v1/users/me/profile \ + -H "Authorization: Bearer $GOOGLE_WORKSPACE_CLI_TOKEN" -o /dev/null' + + # Force gateway token rotation, verify sandbox still works + openshell provider refresh rotate gws \ + --credential-key GOOGLE_WORKSPACE_CLI_TOKEN &>/dev/null + step "gws: works after rotation" \ + "$CLI" sandbox exec --name "$sandbox_name" -- bash -c \ + 'curl -sf https://gmail.googleapis.com/gmail/v1/users/me/profile \ + -H "Authorization: Bearer $GOOGLE_WORKSPACE_CLI_TOKEN" -o /dev/null' + + echo "" +} + +# ── kind flow ──────────────────────────────────────────────────────── + +test_kind() { + local mode="quick" + $FULL && mode="full" + echo "=== test-flow: kind ($mode) ===" + + # Verify kind cluster is up + if ! kubectl get nodes &>/dev/null; then + echo " ERROR: no kind cluster — run: kind create cluster --name openshell" + ((FAIL++)) + return + fi + + step "teardown" "$HARNESS" teardown --sandboxes --providers --k8s + step "deploy" "$HARNESS" deploy kind + + if ! $NO_PROVIDERS; then + step "setup providers" "$HARNESS" providers + step "gateway reachable" "$CLI" inference get + check_providers + fi + + if $FULL; then + local sandbox_name="test-kind" + step_output "sandbox create" "$HARNESS" up --name "$sandbox_name" --profile "$PROFILE" --no-tty + sandbox_verify "$sandbox_name" + + if ! $NO_PROVIDERS; then + test_gws "$sandbox_name" + fi + + step "sandbox delete" "$CLI" sandbox delete "$sandbox_name" + fi + + step "teardown (clean)" "$HARNESS" teardown --sandboxes --providers --k8s + echo "" +} + # ── OCP flow ───────────────────────────────────────────────────────── test_ocp() { @@ -266,11 +335,12 @@ test_errors case "$TARGET" in local|podman) test_local ;; + kind) test_kind ;; ocp) test_ocp ;; - all) test_local; echo ""; test_ocp ;; + all) test_local; echo ""; test_kind; echo ""; test_ocp ;; *) echo "Unknown target: $TARGET" - echo "Usage: $0 [--full]" + echo "Usage: $0 [--full]" exit 1 ;; esac From b0ccfb6aee20339a5bfd43f14fb3c3b16e222466 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Sun, 7 Jun 2026 02:51:34 -0700 Subject: [PATCH 04/11] fix: enforce GWS OAuth scopes via refresh material not profile YAML MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The refresh.scopes field in the provider profile YAML is not passed to Google during token refresh — only --material scopes=... is. Move scope enforcement into registerGWS() as a scopes material key so the gateway mints a narrowed access token (gmail.readonly, calendar.readonly, drive.readonly, tasks only). Verified: removing gmail from scopes causes 403 PERMISSION_DENIED on Gmail API calls; calendar with scope retained works fine. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- cmd/providers.go | 6 ++++++ sandbox/profiles/gws.yaml | 5 ----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/cmd/providers.go b/cmd/providers.go index ea9a3cb..7a38c30 100644 --- a/cmd/providers.go +++ b/cmd/providers.go @@ -256,6 +256,8 @@ func registerGWS(gw gateway.Gateway, enabled func(string) bool) error { // Configure gateway-managed OAuth refresh. The gateway stores client_secret // and refresh_token as secret material — they are never injected into sandboxes. + // Scopes are passed as material so Google mints a narrowed access token — + // only these scopes are accessible even though the refresh_token has more. if err := gw.ProviderRefreshConfigure("gws", gateway.ProviderRefreshOpts{ CredentialKey: "GOOGLE_WORKSPACE_CLI_TOKEN", Strategy: "oauth2-refresh-token", @@ -263,6 +265,10 @@ func registerGWS(gw gateway.Gateway, enabled func(string) bool) error { "client_id=" + creds.ClientID, "client_secret=" + creds.ClientSecret, "refresh_token=" + creds.RefreshToken, + "scopes=https://www.googleapis.com/auth/gmail.readonly" + + " https://www.googleapis.com/auth/calendar.readonly" + + " https://www.googleapis.com/auth/drive.readonly" + + " https://www.googleapis.com/auth/tasks", }, SecretMaterialKeys: []string{"client_secret", "refresh_token"}, }); err != nil { diff --git a/sandbox/profiles/gws.yaml b/sandbox/profiles/gws.yaml index 5cb97bc..5c101fe 100644 --- a/sandbox/profiles/gws.yaml +++ b/sandbox/profiles/gws.yaml @@ -27,11 +27,6 @@ credentials: refresh: strategy: oauth2_refresh_token token_url: https://oauth2.googleapis.com/token - scopes: - - https://www.googleapis.com/auth/gmail.readonly - - https://www.googleapis.com/auth/calendar.readonly - - https://www.googleapis.com/auth/drive.readonly - - https://www.googleapis.com/auth/tasks refresh_before_seconds: 300 max_lifetime_seconds: 3600 material: From 252553843fc367a23d28ff60ba57777b31997a6c Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Sun, 7 Jun 2026 02:54:50 -0700 Subject: [PATCH 05/11] chore: tighten GWS to read-only scopes, drop curl from binaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch tasks scope from read-write to tasks.readonly (read-only variant). GWS scopes are now fully read-only: gmail.readonly, calendar.readonly, drive.readonly, tasks.readonly. Remove curl from gws profile binaries — it was only there for scope enforcement testing. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- cmd/providers.go | 5 +++-- sandbox/profiles/gws.yaml | 4 +--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/cmd/providers.go b/cmd/providers.go index 7a38c30..bef02df 100644 --- a/cmd/providers.go +++ b/cmd/providers.go @@ -265,10 +265,11 @@ func registerGWS(gw gateway.Gateway, enabled func(string) bool) error { "client_id=" + creds.ClientID, "client_secret=" + creds.ClientSecret, "refresh_token=" + creds.RefreshToken, - "scopes=https://www.googleapis.com/auth/gmail.readonly" + + "scopes=" + + "https://www.googleapis.com/auth/gmail.readonly" + " https://www.googleapis.com/auth/calendar.readonly" + " https://www.googleapis.com/auth/drive.readonly" + - " https://www.googleapis.com/auth/tasks", + " https://www.googleapis.com/auth/tasks.readonly", }, SecretMaterialKeys: []string{"client_secret", "refresh_token"}, }); err != nil { diff --git a/sandbox/profiles/gws.yaml b/sandbox/profiles/gws.yaml index 5c101fe..fb0112b 100644 --- a/sandbox/profiles/gws.yaml +++ b/sandbox/profiles/gws.yaml @@ -74,7 +74,7 @@ endpoints: - host: tasks.googleapis.com port: 443 protocol: rest - access: read-write + access: read-only enforcement: enforce - host: oauth2.googleapis.com port: 443 @@ -90,5 +90,3 @@ endpoints: binaries: - /usr/local/bin/gws - /sandbox/.local/bin/gws - - /usr/bin/curl - - /usr/local/bin/curl From 8ed479fdb0f9ca4b45caecf43ca489194b3555a5 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Sun, 7 Jun 2026 03:00:02 -0700 Subject: [PATCH 06/11] refactor: read GWS OAuth scopes from profile YAML MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move scopes out of providers.go and into sandbox/profiles/gws.yaml under the refresh section — single source of truth for GWS config. registerGWS() reads refresh.scopes from the profile via gwsProfileScopes() and passes them as material to provider refresh configure. Falls back to no scope restriction if the profile is missing or unparseable. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- cmd/providers.go | 52 ++++++++++++++++++++++++++++----------- sandbox/profiles/gws.yaml | 5 ++++ 2 files changed, 43 insertions(+), 14 deletions(-) diff --git a/cmd/providers.go b/cmd/providers.go index bef02df..6c221cf 100644 --- a/cmd/providers.go +++ b/cmd/providers.go @@ -11,6 +11,7 @@ import ( "github.com/robbycochran/harness-openshell/internal/gateway" "github.com/robbycochran/harness-openshell/internal/status" "github.com/spf13/cobra" + "gopkg.in/yaml.v3" ) func NewProvidersCmd(harnessDir, cli string) *cobra.Command { @@ -95,7 +96,7 @@ func registerProviders(harnessDir string, gw gateway.Gateway, force bool, gwCfg if err := registerAtlassian(gw, providerEnabled); err != nil { return err } - if err := registerGWS(gw, providerEnabled); err != nil { + if err := registerGWS(harnessDir, gw, providerEnabled); err != nil { return err } @@ -212,7 +213,7 @@ func registerAtlassian(gw gateway.Gateway, enabled func(string) bool) error { return nil } -func registerGWS(gw gateway.Gateway, enabled func(string) bool) error { +func registerGWS(harnessDir string, gw gateway.Gateway, enabled func(string) bool) error { if !enabled("gws") { status.Info("gws: disabled by gateway config") return nil @@ -254,23 +255,25 @@ func registerGWS(gw gateway.Gateway, enabled func(string) bool) error { return fmt.Errorf("creating gws provider: %w", err) } + // Read scopes from the provider profile so they're defined in one place. + profileScopes := gwsProfileScopes(harnessDir) + // Configure gateway-managed OAuth refresh. The gateway stores client_secret // and refresh_token as secret material — they are never injected into sandboxes. // Scopes are passed as material so Google mints a narrowed access token — // only these scopes are accessible even though the refresh_token has more. + material := []string{ + "client_id=" + creds.ClientID, + "client_secret=" + creds.ClientSecret, + "refresh_token=" + creds.RefreshToken, + } + if profileScopes != "" { + material = append(material, "scopes="+profileScopes) + } if err := gw.ProviderRefreshConfigure("gws", gateway.ProviderRefreshOpts{ - CredentialKey: "GOOGLE_WORKSPACE_CLI_TOKEN", - Strategy: "oauth2-refresh-token", - Material: []string{ - "client_id=" + creds.ClientID, - "client_secret=" + creds.ClientSecret, - "refresh_token=" + creds.RefreshToken, - "scopes=" + - "https://www.googleapis.com/auth/gmail.readonly" + - " https://www.googleapis.com/auth/calendar.readonly" + - " https://www.googleapis.com/auth/drive.readonly" + - " https://www.googleapis.com/auth/tasks.readonly", - }, + CredentialKey: "GOOGLE_WORKSPACE_CLI_TOKEN", + Strategy: "oauth2-refresh-token", + Material: material, SecretMaterialKeys: []string{"client_secret", "refresh_token"}, }); err != nil { return fmt.Errorf("configuring gws refresh: %w", err) @@ -285,6 +288,27 @@ func registerGWS(gw gateway.Gateway, enabled func(string) bool) error { return nil } +// gwsProfileScopes reads the refresh.scopes list from sandbox/profiles/gws.yaml +// and returns them as a space-separated string for use as OAuth scope material. +func gwsProfileScopes(harnessDir string) string { + profilePath := filepath.Join(harnessDir, "sandbox", "profiles", "gws.yaml") + data, err := os.ReadFile(profilePath) + if err != nil { + return "" + } + var profile struct { + Credentials []struct { + Refresh struct { + Scopes []string `yaml:"scopes"` + } `yaml:"refresh"` + } `yaml:"credentials"` + } + if err := yaml.Unmarshal(data, &profile); err != nil || len(profile.Credentials) == 0 { + return "" + } + return strings.Join(profile.Credentials[0].Refresh.Scopes, " ") +} + func deleteCustomProfiles(harnessDir string, gw gateway.Gateway) { profilesDir := filepath.Join(harnessDir, "sandbox", "profiles") entries, err := os.ReadDir(profilesDir) diff --git a/sandbox/profiles/gws.yaml b/sandbox/profiles/gws.yaml index fb0112b..236fdce 100644 --- a/sandbox/profiles/gws.yaml +++ b/sandbox/profiles/gws.yaml @@ -27,6 +27,11 @@ credentials: refresh: strategy: oauth2_refresh_token token_url: https://oauth2.googleapis.com/token + scopes: + - https://www.googleapis.com/auth/gmail.readonly + - https://www.googleapis.com/auth/calendar.readonly + - https://www.googleapis.com/auth/drive.readonly + - https://www.googleapis.com/auth/tasks.readonly refresh_before_seconds: 300 max_lifetime_seconds: 3600 material: From e96dfd107206e071015891b9f813520ca87048a8 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Sun, 7 Jun 2026 06:52:36 -0700 Subject: [PATCH 07/11] =?UTF-8?q?fix:=20tasks.readonly=20not=20in=20token?= =?UTF-8?q?=20=E2=80=94=20use=20tasks=20scope,=20restore=20curl=20to=20bin?= =?UTF-8?q?aries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tasks.readonly is not a scope the gws refresh token was granted (only tasks read-write). Fix gws.yaml to use the correct tasks scope. Restore curl to the profile binaries list so the proxy allows it for direct API calls and test-flow.sh sandbox verification. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- sandbox/profiles/gws.yaml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/sandbox/profiles/gws.yaml b/sandbox/profiles/gws.yaml index 236fdce..5cb97bc 100644 --- a/sandbox/profiles/gws.yaml +++ b/sandbox/profiles/gws.yaml @@ -31,7 +31,7 @@ credentials: - https://www.googleapis.com/auth/gmail.readonly - https://www.googleapis.com/auth/calendar.readonly - https://www.googleapis.com/auth/drive.readonly - - https://www.googleapis.com/auth/tasks.readonly + - https://www.googleapis.com/auth/tasks refresh_before_seconds: 300 max_lifetime_seconds: 3600 material: @@ -79,7 +79,7 @@ endpoints: - host: tasks.googleapis.com port: 443 protocol: rest - access: read-only + access: read-write enforcement: enforce - host: oauth2.googleapis.com port: 443 @@ -95,3 +95,5 @@ endpoints: binaries: - /usr/local/bin/gws - /sandbox/.local/bin/gws + - /usr/bin/curl + - /usr/local/bin/curl From 8791b188f5b8bafab4bc8711415d0b69097fe193 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Sun, 7 Jun 2026 09:52:13 -0700 Subject: [PATCH 08/11] ci: add kind integration job to GitHub Actions Runs harness deploy kind + test-flow.sh kind --full --no-providers --profile=ci on ubuntu-latest. Uses the public community base image, no credentials needed. Installs kind and helm via the official scripts. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .github/workflows/integration.yml | 46 +++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index d0c1d7b..7dc2933 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -47,3 +47,49 @@ jobs: with: name: gateway-logs-local path: /tmp/gateway-logs/ + + kind: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Build harness CLI + run: make cli + + - name: Install openshell + run: curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | sh + + - name: Install kind + run: | + curl -Lo /usr/local/bin/kind https://kind.sigs.k8s.io/dl/latest/kind-linux-amd64 + chmod +x /usr/local/bin/kind + + - name: Install helm + run: | + curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash + + - name: Create kind cluster + run: kind create cluster --name openshell --wait 60s + + - name: Deploy gateway to kind + run: ./harness deploy kind + + - name: Run integration tests + run: ./test/test-flow.sh kind --full --no-providers --profile=ci + + - name: Export logs + if: always() + run: | + mkdir -p /tmp/kind-logs + kubectl -n openshell logs statefulset/openshell > /tmp/kind-logs/gateway.log 2>/dev/null || true + kubectl -n openshell get all > /tmp/kind-logs/resources.txt 2>&1 || true + openshell gateway list > /tmp/kind-logs/gateways.txt 2>&1 || true + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: gateway-logs-kind + path: /tmp/kind-logs/ From a7b69a88c717680a48be7ce2714b3eb704aecfde Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Sun, 7 Jun 2026 09:54:19 -0700 Subject: [PATCH 09/11] ci: use kind-action and setup-helm for kind job Replace manual curl install (which fails due to /usr/local/bin permissions) with helm/kind-action@v1 and azure/setup-helm@v4 which handle installation correctly on GitHub Actions runners. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .github/workflows/integration.yml | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 7dc2933..0fced3d 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -62,17 +62,12 @@ jobs: - name: Install openshell run: curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | sh - - name: Install kind - run: | - curl -Lo /usr/local/bin/kind https://kind.sigs.k8s.io/dl/latest/kind-linux-amd64 - chmod +x /usr/local/bin/kind + - uses: helm/kind-action@v1 + with: + cluster_name: openshell - name: Install helm - run: | - curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash - - - name: Create kind cluster - run: kind create cluster --name openshell --wait 60s + uses: azure/setup-helm@v4 - name: Deploy gateway to kind run: ./harness deploy kind From fbe03c7c118f18a8a988e32d6589bb8f229dffe7 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Sun, 7 Jun 2026 09:59:06 -0700 Subject: [PATCH 10/11] docs: document validation tiers and update workaround table Add Validation section to AGENTS.md: - No-credential flows (CI/kind): what runs in GitHub Actions - Full flows (local dev with personal creds): what requires real credentials - Table showing what each tier covers - Future work note on service accounts for GHA Update workaround table: GWS credential export/upload is resolved (now a native provider with gateway-managed OAuth refresh). Mark as resolved. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- AGENTS.md | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bc5332e..cd23752 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,12 +70,63 @@ Current workarounds and their upstream tracking: | Workaround | Why | Upstream | |------------|-----|----------| -| GWS credential export/upload | gws CLI reads encrypted local files | [#1268](https://github.com/NVIDIA/OpenShell/issues/1268), [#1423](https://github.com/NVIDIA/OpenShell/issues/1423) | | Custom gateway image | `google-vertex-ai` provider not in released builds yet | Will ship in upstream release | | `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1` | Vertex AI rejects `context_management` beta header | Anthropic/Google to align APIs | -| Atlassian `JIRA_URL`/`JIRA_USERNAME` as uploaded config | Provider v2 config keys not injected as env vars yet | OpenShell roadmap | +| Atlassian `JIRA_URL`/`JIRA_USERNAME` as `--config` material | Provider v2 config keys not injected as env vars yet | OpenShell roadmap | | In-cluster launcher Job | OpenShell doesn't have a native K8s-triggered sandbox creation | Potential future CRD | +Previously worked around, now resolved: + +| Was | Resolution | +|-----|-----------| +| GWS credential export/upload to sandbox | GWS is now a native provider (`google-workspace`). Gateway manages OAuth refresh via `oauth2_refresh_token` strategy + `request_body_credential_rewrite` on `oauth2.googleapis.com`. Sandbox gets a proxy-resolved placeholder. | + +## Validation + +There are two validation tiers, depending on whether real credentials are available. + +### No-credential flows (CI / kind) + +Run without any provider credentials. Tests sandbox lifecycle only — deploy, create, exec, delete. + +```bash +make validate-kind # kind cluster (local) +./test/test-flow.sh kind --full --no-providers --profile=ci +``` + +The `ci` profile uses the public community base image and attaches no providers. These flows run in GitHub Actions on every PR (the `kind` job in `.github/workflows/integration.yml`). + +### Full flows (local dev, personal credentials) + +Run with real credentials. Tests the complete provider chain including GWS OAuth token lifecycle. + +```bash +make validate-local # local Podman gateway +./test/test-flow.sh local --full +``` + +Requires: +- `openshell` gateway running locally (`brew services start openshell`) +- `JIRA_API_TOKEN`, `JIRA_URL`, `JIRA_USERNAME` for Atlassian +- `gcloud auth application-default login` for Vertex AI +- `gws auth login` for Google Workspace +- `GITHUB_TOKEN` for GitHub + +These flows do not run in GitHub Actions today — they require personal OAuth credentials. Future work: service accounts for Vertex AI and Atlassian can run in GHA; GWS would need a dedicated service account. + +### What each flow tests + +| Check | No-cred (CI) | Full (local) | +|-------|-------------|--------------| +| Gateway deploy and rollout | ✓ | ✓ | +| Sandbox create / exec / delete | ✓ | ✓ | +| Provider registration | — | ✓ | +| `GOOGLE_WORKSPACE_CLI_TOKEN` is proxy placeholder | — | ✓ | +| Gmail/Calendar/Drive API via proxy | — | ✓ | +| GWS token rotation survives in sandbox | — | ✓ | +| Inference (Vertex AI) | — | ✓ | +| Atlassian MCP server | — | ✓ | + ## Adding a new integration Before writing custom code: From e47272a499efd3a9c5d3d50fc9b4835db62fad33 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Sun, 7 Jun 2026 10:02:18 -0700 Subject: [PATCH 11/11] refactor: validation modes (default/ci) independent of gateway target default mode: expects user credentials, tests full provider chain ci mode: no credentials, tests gateway deploy + sandbox lifecycle --ci flag on test-flow.sh sets --no-providers --profile=ci --full. Makefile adds validate-local-ci and validate-kind-ci targets. integration.yml local and kind jobs use --ci flag. AGENTS.md documents default vs ci as the primary dimension. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .github/workflows/integration.yml | 8 +++--- AGENTS.md | 47 ++++++++++++++++++++----------- Makefile | 34 ++++++++++++++++++---- test/test-flow.sh | 26 +++++++++++------ 4 files changed, 79 insertions(+), 36 deletions(-) diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 0fced3d..3c12948 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -31,8 +31,8 @@ jobs: done openshell gateway list - - name: Run integration tests - run: ./test/test-flow.sh local --full --no-providers --profile=ci + - name: Run integration tests (ci mode — no credentials) + run: ./test/test-flow.sh local --ci - name: Export gateway logs if: always() @@ -72,8 +72,8 @@ jobs: - name: Deploy gateway to kind run: ./harness deploy kind - - name: Run integration tests - run: ./test/test-flow.sh kind --full --no-providers --profile=ci + - name: Run integration tests (ci mode — no credentials) + run: ./test/test-flow.sh kind --ci - name: Export logs if: always() diff --git a/AGENTS.md b/AGENTS.md index cd23752..d15412d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,41 +83,54 @@ Previously worked around, now resolved: ## Validation -There are two validation tiers, depending on whether real credentials are available. +Validation has two modes — **default** and **ci** — that are independent of where +the gateway runs (local Podman, kind, OCP). The mode controls what is tested, not +the target. -### No-credential flows (CI / kind) +### Modes -Run without any provider credentials. Tests sandbox lifecycle only — deploy, create, exec, delete. +**`default`** — expects user credentials. Tests the full stack including provider +registration, credential injection, and the GWS OAuth token lifecycle. -```bash -make validate-kind # kind cluster (local) -./test/test-flow.sh kind --full --no-providers --profile=ci -``` +**`ci`** — no credentials required. Tests gateway deploy and sandbox lifecycle only. +Runs in GitHub Actions on every PR. -The `ci` profile uses the public community base image and attaches no providers. These flows run in GitHub Actions on every PR (the `kind` job in `.github/workflows/integration.yml`). +``` +--ci flag = --no-providers --profile=ci --full +``` -### Full flows (local dev, personal credentials) +### Make targets -Run with real credentials. Tests the complete provider chain including GWS OAuth token lifecycle. +| Target | Gateway | Mode | +|--------|---------|------| +| `make validate-local` | local Podman | default (needs creds) | +| `make validate-local-ci` | local Podman | ci (no creds) | +| `make validate-kind` | kind cluster | default (needs creds) | +| `make validate-kind-ci` | kind cluster | ci (no creds) | +Or directly: ```bash -make validate-local # local Podman gateway -./test/test-flow.sh local --full +./test/test-flow.sh local # default mode +./test/test-flow.sh local --ci # ci mode +./test/test-flow.sh kind --ci # used in GitHub Actions ``` -Requires: +### Default mode requirements + - `openshell` gateway running locally (`brew services start openshell`) - `JIRA_API_TOKEN`, `JIRA_URL`, `JIRA_USERNAME` for Atlassian - `gcloud auth application-default login` for Vertex AI - `gws auth login` for Google Workspace - `GITHUB_TOKEN` for GitHub -These flows do not run in GitHub Actions today — they require personal OAuth credentials. Future work: service accounts for Vertex AI and Atlassian can run in GHA; GWS would need a dedicated service account. +Default mode does not run in GitHub Actions today — it requires personal OAuth +credentials. Future: service accounts for Vertex AI and Atlassian can run in GHA; +GWS would need a dedicated OAuth service account. -### What each flow tests +### What each mode tests -| Check | No-cred (CI) | Full (local) | -|-------|-------------|--------------| +| Check | ci | default | +|-------|----|---------| | Gateway deploy and rollout | ✓ | ✓ | | Sandbox create / exec / delete | ✓ | ✓ | | Provider registration | — | ✓ | diff --git a/Makefile b/Makefile index 1e2f9dd..6e5d1ea 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,8 @@ DEV_LAUNCHER_IMAGE := $(DEV_REGISTRY):$(DEV_TAG)-launcher .PHONY: cli sandbox push-sandbox cli-launcher launcher push-launcher \ vet lint test-unit test test-local test-kind test-ocp test-all validate \ - validate-local validate-kind dev-sandbox dev-launcher validate-dev clean help + validate-local validate-local-ci validate-kind validate-kind-ci \ + dev-sandbox dev-launcher validate-dev clean help ## ── CLI ────────────────────────────────────────────────────────────── @@ -151,8 +152,9 @@ validate-dev: cli dev-sandbox dev-launcher @echo "=== Integration: OCP (full) ===" SANDBOX_IMAGE=$(DEV_SANDBOX_IMAGE) LAUNCHER_IMAGE=$(DEV_LAUNCHER_IMAGE) ./test/test-flow.sh ocp --full -## Local validate: unit tests + bats + full local integration with GWS lifecycle test. -## Requires: openshell gateway running locally (brew services start openshell). +## Default validation: unit tests + bats + full integration with user credentials. +## Requires: openshell gateway running locally (brew services start openshell), +## JIRA_API_TOKEN, gcloud ADC, gws auth login. validate-local: cli @echo "=== Unit tests ===" CGO_ENABLED=0 go test ./... @@ -161,19 +163,39 @@ validate-local: cli @echo "=== Bats ===" bats test/preflight.bats @echo "" - @echo "=== Integration: local (full, with GWS) ===" + @echo "=== Integration: local gateway, default mode ===" ./test/test-flow.sh local --full -## kind validate: unit tests + full kind integration with GWS lifecycle test. +## CI validation: unit tests + integration without credentials (local gateway). +## Requires: openshell gateway running locally only. +validate-local-ci: cli + @echo "=== Unit tests ===" + CGO_ENABLED=0 go test ./... + cd sandbox/launcher && go test ./... + @echo "" + @echo "=== Integration: local gateway, ci mode ===" + ./test/test-flow.sh local --ci + +## Default validation on kind: unit tests + full integration with user credentials. ## Requires: kind create cluster --name openshell validate-kind: cli @echo "=== Unit tests ===" CGO_ENABLED=0 go test ./... cd sandbox/launcher && go test ./... @echo "" - @echo "=== Integration: kind (full, with GWS) ===" + @echo "=== Integration: kind gateway, default mode ===" ./test/test-flow.sh kind --full +## CI validation on kind: unit tests + integration without credentials. +## Requires: kind create cluster --name openshell +validate-kind-ci: cli + @echo "=== Unit tests ===" + CGO_ENABLED=0 go test ./... + cd sandbox/launcher && go test ./... + @echo "" + @echo "=== Integration: kind gateway, ci mode ===" + ./test/test-flow.sh kind --ci + ## ── Convenience targets ─────────────────────────────────────────────── ## Clean built binaries diff --git a/test/test-flow.sh b/test/test-flow.sh index 0c38912..9a2f48a 100755 --- a/test/test-flow.sh +++ b/test/test-flow.sh @@ -1,14 +1,21 @@ #!/usr/bin/env bash -# End-to-end validation for local, kind, and OCP flows. +# End-to-end validation. Validation mode (default/ci) is independent of +# the gateway target (local/kind/ocp). +# +# Validation modes: +# default Expects user credentials (GITHUB_TOKEN, JIRA_API_TOKEN, gcloud ADC, +# gws auth). Tests the full provider chain including GWS token lifecycle. +# ci No credentials required. Validates gateway deploy + sandbox lifecycle +# only. Suitable for GitHub Actions. # # Usage: -# ./test-flow.sh local # quick: deploy + providers + teardown -# ./test-flow.sh local --full # full: + sandbox + verify integrations -# ./test-flow.sh local --full --no-providers --profile=ci # CI mode (no creds) -# ./test-flow.sh kind [--full] # kind cluster variants -# ./test-flow.sh ocp [--full] # OCP variants -# ./test-flow.sh ocp --full --reuse-gateway # skip deploy/teardown-k8s (~50s vs ~130s) -# ./test-flow.sh all [--full] # all platforms +# ./test-flow.sh local # default mode, local gateway +# ./test-flow.sh local --ci # ci mode, local gateway +# ./test-flow.sh kind # default mode, kind cluster +# ./test-flow.sh kind --ci # ci mode, kind cluster (used in GHA) +# ./test-flow.sh ocp [--ci] # OCP variants +# ./test-flow.sh ocp --reuse-gateway # skip deploy/teardown (~50s vs ~130s) +# ./test-flow.sh all [--ci] # all gateways set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" @@ -30,6 +37,7 @@ PROFILE="default" for arg in "$@"; do case "$arg" in + --ci) NO_PROVIDERS=true; PROFILE="ci"; FULL=true ;; --full) FULL=true ;; --reuse-gateway) REUSE_GATEWAY=true ;; --no-providers) NO_PROVIDERS=true ;; @@ -40,7 +48,7 @@ for arg in "$@"; do done if [[ -z "$TARGET" ]]; then - echo "Usage: $0 [--full] [--reuse-gateway]" + echo "Usage: $0 [--ci] [--full] [--reuse-gateway]" exit 1 fi