diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index d0c1d7b..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() @@ -47,3 +47,44 @@ 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 + + - uses: helm/kind-action@v1 + with: + cluster_name: openshell + + - name: Install helm + uses: azure/setup-helm@v4 + + - name: Deploy gateway to kind + run: ./harness deploy kind + + - name: Run integration tests (ci mode — no credentials) + run: ./test/test-flow.sh kind --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/ diff --git a/AGENTS.md b/AGENTS.md index bc5332e..d15412d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,12 +70,76 @@ 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 + +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. + +### Modes + +**`default`** — expects user credentials. Tests the full stack including provider +registration, credential injection, and the GWS OAuth token lifecycle. + +**`ci`** — no credentials required. Tests gateway deploy and sandbox lifecycle only. +Runs in GitHub Actions on every PR. + +``` +--ci flag = --no-providers --profile=ci --full +``` + +### Make targets + +| 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 +./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 +``` + +### 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 + +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 mode tests + +| Check | ci | default | +|-------|----|---------| +| 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: diff --git a/Makefile b/Makefile index be0f0e8..6e5d1ea 100644 --- a/Makefile +++ b/Makefile @@ -18,7 +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 \ + vet lint test-unit test test-local test-kind test-ocp test-all validate \ + validate-local validate-local-ci validate-kind validate-kind-ci \ dev-sandbox dev-launcher validate-dev clean help ## ── CLI ────────────────────────────────────────────────────────────── @@ -84,11 +85,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 +152,50 @@ 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 +## 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 ./... + cd sandbox/launcher && go test ./... + @echo "" + @echo "=== Bats ===" + bats test/preflight.bats + @echo "" + @echo "=== Integration: local gateway, default mode ===" + ./test/test-flow.sh local --full + +## 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 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/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/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 8fa0e60..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) @@ -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..6c221cf 100644 --- a/cmd/providers.go +++ b/cmd/providers.go @@ -4,12 +4,14 @@ import ( "encoding/json" "fmt" "os" + "os/exec" "path/filepath" "strings" "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 { @@ -60,7 +62,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 +96,9 @@ func registerProviders(harnessDir string, gw gateway.Gateway, force bool, gwCfg if err := registerAtlassian(gw, providerEnabled); err != nil { return err } + if err := registerGWS(harnessDir, gw, providerEnabled); err != nil { + return err + } // Show results status.Section("Providers") @@ -208,6 +213,102 @@ func registerAtlassian(gw gateway.Gateway, enabled func(string) bool) error { return nil } +func registerGWS(harnessDir string, 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) + } + + // 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: material, + 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 +} + +// 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/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/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/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..fe5a7cd 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) } @@ -163,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 3be82e2..989c7b7 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) @@ -26,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 @@ -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/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/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 diff --git a/test/test-flow.sh b/test/test-flow.sh index b975bce..9a2f48a 100755 --- a/test/test-flow.sh +++ b/test/test-flow.sh @@ -1,13 +1,21 @@ #!/usr/bin/env bash -# End-to-end validation for local 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 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 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)" @@ -29,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 ;; @@ -39,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 @@ -131,7 +140,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 +221,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 +343,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