diff --git a/cmd/agents.go b/cmd/agents.go deleted file mode 100644 index 718306f..0000000 --- a/cmd/agents.go +++ /dev/null @@ -1,1360 +0,0 @@ -package cmd - -import ( - "context" - "encoding/json" - "fmt" - "strings" - "time" - - "github.com/kernel/cli/pkg/util" - "github.com/kernel/kernel-go-sdk" - "github.com/kernel/kernel-go-sdk/option" - "github.com/kernel/kernel-go-sdk/packages/pagination" - "github.com/pkg/browser" - "github.com/pquerna/otp/totp" - "github.com/pterm/pterm" - "github.com/spf13/cobra" -) - -// AgentAuthService defines the subset of the Kernel SDK agent auth client that we use. -type AgentAuthService interface { - New(ctx context.Context, body kernel.AgentAuthNewParams, opts ...option.RequestOption) (res *kernel.AuthAgent, err error) - Get(ctx context.Context, id string, opts ...option.RequestOption) (res *kernel.AuthAgent, err error) - List(ctx context.Context, query kernel.AgentAuthListParams, opts ...option.RequestOption) (res *pagination.OffsetPagination[kernel.AuthAgent], err error) - Delete(ctx context.Context, id string, opts ...option.RequestOption) (err error) -} - -// AgentAuthInvocationsService defines the subset of the Kernel SDK agent auth invocations client that we use. -type AgentAuthInvocationsService interface { - New(ctx context.Context, body kernel.AgentAuthInvocationNewParams, opts ...option.RequestOption) (res *kernel.AuthAgentInvocationCreateResponse, err error) - Get(ctx context.Context, invocationID string, opts ...option.RequestOption) (res *kernel.AgentAuthInvocationResponse, err error) - Exchange(ctx context.Context, invocationID string, body kernel.AgentAuthInvocationExchangeParams, opts ...option.RequestOption) (res *kernel.AgentAuthInvocationExchangeResponse, err error) - Submit(ctx context.Context, invocationID string, body kernel.AgentAuthInvocationSubmitParams, opts ...option.RequestOption) (res *kernel.AgentAuthSubmitResponse, err error) -} - -// AgentAuthCmd handles agent auth operations independent of cobra. -type AgentAuthCmd struct { - auth AgentAuthService - invocations AgentAuthInvocationsService -} - -type AgentAuthCreateInput struct { - Domain string - ProfileName string - CredentialName string - LoginURL string - AllowedDomains []string - ProxyID string - Output string -} - -type AgentAuthGetInput struct { - ID string - Output string -} - -type AgentAuthListInput struct { - Domain string - ProfileName string - Limit int - Offset int - Output string -} - -type AgentAuthDeleteInput struct { - ID string - SkipConfirm bool -} - -type AgentAuthInvocationCreateInput struct { - AuthAgentID string - SaveCredentialAs string - Output string -} - -type AgentAuthInvocationGetInput struct { - InvocationID string - Output string -} - -type AgentAuthInvocationExchangeInput struct { - InvocationID string - Code string - Output string -} - -type AgentAuthInvocationSubmitInput struct { - InvocationID string - FieldValues map[string]string - SSOButton string - SelectedMfaType string - Output string -} - -// AgentAuthRunInput contains all parameters for the automated auth run flow. -type AgentAuthRunInput struct { - Domain string - ProfileName string - Values map[string]string - CredentialName string - SaveCredentialAs string - TotpSecret string - ProxyID string - LoginURL string - AllowedDomains []string - Timeout time.Duration - OpenLiveView bool - Output string -} - -// AgentAuthRunResult is the result of a successful auth run. -type AgentAuthRunResult struct { - ProfileName string `json:"profile_name"` - ProfileID string `json:"profile_id"` - Domain string `json:"domain"` - AuthAgentID string `json:"auth_agent_id"` -} - -// AgentAuthRunEvent represents a status update during the auth run (for JSON output). -type AgentAuthRunEvent struct { - Type string `json:"type"` // status, error, success, waiting - Step string `json:"step,omitempty"` - Status string `json:"status,omitempty"` - Message string `json:"message,omitempty"` - LiveViewURL string `json:"live_view_url,omitempty"` -} - -// AgentAuthRunCmd handles the automated auth run flow. -type AgentAuthRunCmd struct { - auth AgentAuthService - invocations AgentAuthInvocationsService - profiles ProfilesService - credentials CredentialsService -} - -func (c AgentAuthCmd) Create(ctx context.Context, in AgentAuthCreateInput) error { - if in.Output != "" && in.Output != "json" { - return fmt.Errorf("unsupported --output value: use 'json'") - } - - if in.Domain == "" { - return fmt.Errorf("--domain is required") - } - if in.ProfileName == "" { - return fmt.Errorf("--profile-name is required") - } - - params := kernel.AgentAuthNewParams{ - AuthAgentCreateRequest: kernel.AuthAgentCreateRequestParam{ - Domain: in.Domain, - ProfileName: in.ProfileName, - }, - } - if in.CredentialName != "" { - params.AuthAgentCreateRequest.CredentialName = kernel.Opt(in.CredentialName) - } - if in.LoginURL != "" { - params.AuthAgentCreateRequest.LoginURL = kernel.Opt(in.LoginURL) - } - if len(in.AllowedDomains) > 0 { - params.AuthAgentCreateRequest.AllowedDomains = in.AllowedDomains - } - if in.ProxyID != "" { - params.AuthAgentCreateRequest.Proxy = kernel.AuthAgentCreateRequestProxyParam{ - ProxyID: kernel.Opt(in.ProxyID), - } - } - - if in.Output != "json" { - pterm.Info.Printf("Creating auth agent for %s...\n", in.Domain) - } - - agent, err := c.auth.New(ctx, params) - if err != nil { - return util.CleanedUpSdkError{Err: err} - } - - if in.Output == "json" { - return util.PrintPrettyJSON(agent) - } - - pterm.Success.Printf("Created auth agent: %s\n", agent.ID) - - tableData := pterm.TableData{ - {"Property", "Value"}, - {"ID", agent.ID}, - {"Domain", agent.Domain}, - {"Profile Name", agent.ProfileName}, - {"Status", string(agent.Status)}, - {"Can Reauth", fmt.Sprintf("%t", agent.CanReauth)}, - } - if agent.CredentialName != "" { - tableData = append(tableData, []string{"Credential Name", agent.CredentialName}) - } - - PrintTableNoPad(tableData, true) - return nil -} - -func (c AgentAuthCmd) Get(ctx context.Context, in AgentAuthGetInput) error { - if in.Output != "" && in.Output != "json" { - return fmt.Errorf("unsupported --output value: use 'json'") - } - - agent, err := c.auth.Get(ctx, in.ID) - if err != nil { - return util.CleanedUpSdkError{Err: err} - } - - if in.Output == "json" { - return util.PrintPrettyJSON(agent) - } - - tableData := pterm.TableData{ - {"Property", "Value"}, - {"ID", agent.ID}, - {"Domain", agent.Domain}, - {"Profile Name", agent.ProfileName}, - {"Status", string(agent.Status)}, - {"Can Reauth", fmt.Sprintf("%t", agent.CanReauth)}, - {"Has Selectors", fmt.Sprintf("%t", agent.HasSelectors)}, - } - if agent.CredentialID != "" { - tableData = append(tableData, []string{"Credential ID", agent.CredentialID}) - } - if agent.CredentialName != "" { - tableData = append(tableData, []string{"Credential Name", agent.CredentialName}) - } - if agent.PostLoginURL != "" { - tableData = append(tableData, []string{"Post-Login URL", agent.PostLoginURL}) - } - if !agent.LastAuthCheckAt.IsZero() { - tableData = append(tableData, []string{"Last Auth Check", util.FormatLocal(agent.LastAuthCheckAt)}) - } - if len(agent.AllowedDomains) > 0 { - tableData = append(tableData, []string{"Allowed Domains", strings.Join(agent.AllowedDomains, ", ")}) - } - - PrintTableNoPad(tableData, true) - return nil -} - -func (c AgentAuthCmd) List(ctx context.Context, in AgentAuthListInput) error { - if in.Output != "" && in.Output != "json" { - return fmt.Errorf("unsupported --output value: use 'json'") - } - - params := kernel.AgentAuthListParams{} - if in.Domain != "" { - params.Domain = kernel.Opt(in.Domain) - } - if in.ProfileName != "" { - params.ProfileName = kernel.Opt(in.ProfileName) - } - if in.Limit > 0 { - params.Limit = kernel.Opt(int64(in.Limit)) - } - if in.Offset > 0 { - params.Offset = kernel.Opt(int64(in.Offset)) - } - - page, err := c.auth.List(ctx, params) - if err != nil { - return util.CleanedUpSdkError{Err: err} - } - - var agents []kernel.AuthAgent - if page != nil { - agents = page.Items - } - - if in.Output == "json" { - if len(agents) == 0 { - fmt.Println("[]") - return nil - } - return util.PrintPrettyJSONSlice(agents) - } - - if len(agents) == 0 { - pterm.Info.Println("No auth agents found") - return nil - } - - tableData := pterm.TableData{{"ID", "Domain", "Profile Name", "Status", "Can Reauth"}} - for _, agent := range agents { - tableData = append(tableData, []string{ - agent.ID, - agent.Domain, - agent.ProfileName, - string(agent.Status), - fmt.Sprintf("%t", agent.CanReauth), - }) - } - - PrintTableNoPad(tableData, true) - return nil -} - -func (c AgentAuthCmd) Delete(ctx context.Context, in AgentAuthDeleteInput) error { - if !in.SkipConfirm { - msg := fmt.Sprintf("Are you sure you want to delete auth agent '%s'?", in.ID) - pterm.DefaultInteractiveConfirm.DefaultText = msg - ok, _ := pterm.DefaultInteractiveConfirm.Show() - if !ok { - pterm.Info.Println("Deletion cancelled") - return nil - } - } - - if err := c.auth.Delete(ctx, in.ID); err != nil { - if util.IsNotFound(err) { - pterm.Info.Printf("Auth agent '%s' not found\n", in.ID) - return nil - } - return util.CleanedUpSdkError{Err: err} - } - pterm.Success.Printf("Deleted auth agent: %s\n", in.ID) - return nil -} - -func (c AgentAuthCmd) InvocationCreate(ctx context.Context, in AgentAuthInvocationCreateInput) error { - if in.Output != "" && in.Output != "json" { - return fmt.Errorf("unsupported --output value: use 'json'") - } - - if in.AuthAgentID == "" { - return fmt.Errorf("--auth-agent-id is required") - } - - params := kernel.AgentAuthInvocationNewParams{ - AuthAgentInvocationCreateRequest: kernel.AuthAgentInvocationCreateRequestParam{ - AuthAgentID: in.AuthAgentID, - }, - } - if in.SaveCredentialAs != "" { - params.AuthAgentInvocationCreateRequest.SaveCredentialAs = kernel.Opt(in.SaveCredentialAs) - } - - if in.Output != "json" { - pterm.Info.Println("Creating auth invocation...") - } - - resp, err := c.invocations.New(ctx, params) - if err != nil { - return util.CleanedUpSdkError{Err: err} - } - - if in.Output == "json" { - return util.PrintPrettyJSON(resp) - } - - pterm.Success.Printf("Created invocation: %s\n", resp.InvocationID) - - tableData := pterm.TableData{ - {"Property", "Value"}, - {"Invocation ID", resp.InvocationID}, - {"Type", string(resp.Type)}, - {"Handoff Code", resp.HandoffCode}, - {"Hosted URL", resp.HostedURL}, - {"Expires At", util.FormatLocal(resp.ExpiresAt)}, - } - - PrintTableNoPad(tableData, true) - return nil -} - -func (c AgentAuthCmd) InvocationGet(ctx context.Context, in AgentAuthInvocationGetInput) error { - if in.Output != "" && in.Output != "json" { - return fmt.Errorf("unsupported --output value: use 'json'") - } - - resp, err := c.invocations.Get(ctx, in.InvocationID) - if err != nil { - return util.CleanedUpSdkError{Err: err} - } - - if in.Output == "json" { - return util.PrintPrettyJSON(resp) - } - - tableData := pterm.TableData{ - {"Property", "Value"}, - {"App Name", resp.AppName}, - {"Domain", resp.Domain}, - {"Type", string(resp.Type)}, - {"Status", string(resp.Status)}, - {"Step", string(resp.Step)}, - {"Expires At", util.FormatLocal(resp.ExpiresAt)}, - } - if resp.LiveViewURL != "" { - tableData = append(tableData, []string{"Live View URL", resp.LiveViewURL}) - } - if resp.ErrorMessage != "" { - tableData = append(tableData, []string{"Error Message", resp.ErrorMessage}) - } - if resp.ExternalActionMessage != "" { - tableData = append(tableData, []string{"External Action", resp.ExternalActionMessage}) - } - if len(resp.PendingFields) > 0 { - var fields []string - for _, f := range resp.PendingFields { - fields = append(fields, f.Name) - } - tableData = append(tableData, []string{"Pending Fields", strings.Join(fields, ", ")}) - } - if len(resp.SubmittedFields) > 0 { - tableData = append(tableData, []string{"Submitted Fields", strings.Join(resp.SubmittedFields, ", ")}) - } - - PrintTableNoPad(tableData, true) - return nil -} - -func (c AgentAuthCmd) InvocationExchange(ctx context.Context, in AgentAuthInvocationExchangeInput) error { - if in.Output != "" && in.Output != "json" { - return fmt.Errorf("unsupported --output value: use 'json'") - } - - if in.Code == "" { - return fmt.Errorf("--code is required") - } - - params := kernel.AgentAuthInvocationExchangeParams{ - Code: in.Code, - } - - resp, err := c.invocations.Exchange(ctx, in.InvocationID, params) - if err != nil { - return util.CleanedUpSdkError{Err: err} - } - - if in.Output == "json" { - return util.PrintPrettyJSON(resp) - } - - pterm.Success.Printf("Exchanged code for JWT\n") - - tableData := pterm.TableData{ - {"Property", "Value"}, - {"Invocation ID", resp.InvocationID}, - {"JWT", resp.Jwt}, - } - - PrintTableNoPad(tableData, true) - return nil -} - -func (c AgentAuthCmd) InvocationSubmit(ctx context.Context, in AgentAuthInvocationSubmitInput) error { - if in.Output != "" && in.Output != "json" { - return fmt.Errorf("unsupported --output value: use 'json'") - } - - // Validate that exactly one of the submit types is provided - hasFields := len(in.FieldValues) > 0 - hasSSO := in.SSOButton != "" - hasMFA := in.SelectedMfaType != "" - - count := 0 - if hasFields { - count++ - } - if hasSSO { - count++ - } - if hasMFA { - count++ - } - - if count == 0 { - return fmt.Errorf("must provide one of: --field (field values), --sso-button, or --mfa-type") - } - if count > 1 { - return fmt.Errorf("can only provide one of: --field (field values), --sso-button, or --mfa-type") - } - - var params kernel.AgentAuthInvocationSubmitParams - if hasFields { - params.OfFieldValues = &kernel.AgentAuthInvocationSubmitParamsBodyFieldValues{ - FieldValues: in.FieldValues, - } - } else if hasSSO { - params.OfSSOButton = &kernel.AgentAuthInvocationSubmitParamsBodySSOButton{ - SSOButton: in.SSOButton, - } - } else if hasMFA { - params.OfSelectedMfaType = &kernel.AgentAuthInvocationSubmitParamsBodySelectedMfaType{ - SelectedMfaType: in.SelectedMfaType, - } - } - - if in.Output != "json" { - pterm.Info.Println("Submitting to invocation...") - } - - resp, err := c.invocations.Submit(ctx, in.InvocationID, params) - if err != nil { - return util.CleanedUpSdkError{Err: err} - } - - if in.Output == "json" { - return util.PrintPrettyJSON(resp) - } - - if resp.Accepted { - pterm.Success.Println("Submission accepted") - } else { - pterm.Warning.Println("Submission not accepted") - } - return nil -} - -const ( - totpPeriod = 30 // TOTP codes are valid for 30-second windows - minSecondsRemaining = 5 // Minimum seconds remaining before we wait for next window -) - -// generateTOTPCode generates a TOTP code from a base32 secret. -// Waits for a fresh window if needed to ensure enough time to submit the code. -// If quiet is true, suppresses human-readable console output (for JSON mode). -func generateTOTPCode(secret string, quiet bool) (string, error) { - // Check if we have enough time in the current window - now := time.Now().Unix() - secondsIntoWindow := now % totpPeriod - remaining := totpPeriod - secondsIntoWindow - - if remaining < minSecondsRemaining { - waitTime := remaining + 1 // Wait until just after the new window starts - if !quiet { - pterm.Info.Printf("TOTP window has only %ds remaining, waiting %ds for fresh window...\n", remaining, waitTime) - } - time.Sleep(time.Duration(waitTime) * time.Second) - } - - // Clean the secret (remove spaces that may be added for readability) - cleanSecret := strings.ReplaceAll(strings.ToUpper(secret), " ", "") - - code, err := totp.GenerateCode(cleanSecret, time.Now()) - if err != nil { - return "", fmt.Errorf("failed to generate TOTP code: %w", err) - } - return code, nil -} - -// Run executes the full automated auth flow: create profile, credential, auth agent, and run invocation to completion. -func (c AgentAuthRunCmd) Run(ctx context.Context, in AgentAuthRunInput) error { - if in.Output != "" && in.Output != "json" { - return fmt.Errorf("unsupported --output value: use 'json'") - } - - if in.Domain == "" { - return fmt.Errorf("--domain is required") - } - if in.ProfileName == "" { - return fmt.Errorf("--profile is required") - } - - // Validate that we have credentials to work with - if in.CredentialName == "" && len(in.Values) == 0 { - return fmt.Errorf("must provide either --credential or --value flags with credentials") - } - - jsonOutput := in.Output == "json" - emitEvent := func(event AgentAuthRunEvent) { - if jsonOutput { - data, _ := json.Marshal(event) - fmt.Println(string(data)) - } - } - - // Step 1: Find or create the profile - if !jsonOutput { - pterm.Info.Printf("Looking for profile '%s'...\n", in.ProfileName) - } - emitEvent(AgentAuthRunEvent{Type: "status", Message: "Looking for profile"}) - - var profileID string - profile, err := c.profiles.Get(ctx, in.ProfileName) - if err != nil { - if !util.IsNotFound(err) { - return util.CleanedUpSdkError{Err: err} - } - // Profile not found, create it - if !jsonOutput { - pterm.Info.Printf("Creating profile '%s'...\n", in.ProfileName) - } - emitEvent(AgentAuthRunEvent{Type: "status", Message: "Creating profile"}) - - newProfile, err := c.profiles.New(ctx, kernel.ProfileNewParams{ - Name: kernel.Opt(in.ProfileName), - }) - if err != nil { - return util.CleanedUpSdkError{Err: err} - } - profileID = newProfile.ID - if !jsonOutput { - pterm.Success.Printf("Created profile: %s\n", newProfile.ID) - } - } else { - profileID = profile.ID - if !jsonOutput { - pterm.Success.Printf("Found existing profile: %s\n", profile.ID) - } - } - - // Step 2: Handle credentials - var credentialName string - if in.CredentialName != "" { - // Using existing credential - credentialName = in.CredentialName - if !jsonOutput { - pterm.Info.Printf("Using existing credential '%s'\n", credentialName) - } - emitEvent(AgentAuthRunEvent{Type: "status", Message: "Using existing credential"}) - } else if in.SaveCredentialAs != "" { - // Create new credential with provided values - credentialName = in.SaveCredentialAs - if !jsonOutput { - pterm.Info.Printf("Creating credential '%s'...\n", credentialName) - } - emitEvent(AgentAuthRunEvent{Type: "status", Message: "Creating credential"}) - - params := kernel.CredentialNewParams{ - CreateCredentialRequest: kernel.CreateCredentialRequestParam{ - Name: credentialName, - Domain: in.Domain, - Values: in.Values, - }, - } - if in.TotpSecret != "" { - params.CreateCredentialRequest.TotpSecret = kernel.Opt(in.TotpSecret) - } - - _, err := c.credentials.New(ctx, params) - if err != nil { - return util.CleanedUpSdkError{Err: err} - } - if !jsonOutput { - pterm.Success.Printf("Created credential: %s\n", credentialName) - } - } - - // Step 3: Create auth agent - if !jsonOutput { - pterm.Info.Printf("Creating auth agent for %s...\n", in.Domain) - } - emitEvent(AgentAuthRunEvent{Type: "status", Message: "Creating auth agent"}) - - agentParams := kernel.AgentAuthNewParams{ - AuthAgentCreateRequest: kernel.AuthAgentCreateRequestParam{ - Domain: in.Domain, - ProfileName: in.ProfileName, - }, - } - if credentialName != "" { - agentParams.AuthAgentCreateRequest.CredentialName = kernel.Opt(credentialName) - } - if in.LoginURL != "" { - agentParams.AuthAgentCreateRequest.LoginURL = kernel.Opt(in.LoginURL) - } - if len(in.AllowedDomains) > 0 { - agentParams.AuthAgentCreateRequest.AllowedDomains = in.AllowedDomains - } - if in.ProxyID != "" { - agentParams.AuthAgentCreateRequest.Proxy = kernel.AuthAgentCreateRequestProxyParam{ - ProxyID: kernel.Opt(in.ProxyID), - } - } - - agent, err := c.auth.New(ctx, agentParams) - if err != nil { - return util.CleanedUpSdkError{Err: err} - } - if !jsonOutput { - pterm.Success.Printf("Created auth agent: %s\n", agent.ID) - } - - // Step 4: Create invocation - if !jsonOutput { - pterm.Info.Println("Starting authentication flow...") - } - emitEvent(AgentAuthRunEvent{Type: "status", Message: "Starting authentication"}) - - invocationParams := kernel.AgentAuthInvocationNewParams{ - AuthAgentInvocationCreateRequest: kernel.AuthAgentInvocationCreateRequestParam{ - AuthAgentID: agent.ID, - }, - } - if in.SaveCredentialAs != "" && credentialName == "" { - // Save credential during invocation if we have values but didn't create upfront - invocationParams.AuthAgentInvocationCreateRequest.SaveCredentialAs = kernel.Opt(in.SaveCredentialAs) - } - - invocation, err := c.invocations.New(ctx, invocationParams) - if err != nil { - return util.CleanedUpSdkError{Err: err} - } - - // Step 5: Polling loop - deadline := time.Now().Add(in.Timeout) - pollInterval := 2 * time.Second - var lastStep string - liveViewShown := false - fieldsSubmitted := make(map[string]bool) - - if !jsonOutput { - pterm.Info.Println("Waiting for authentication to complete...") - } - - for { - if time.Now().After(deadline) { - emitEvent(AgentAuthRunEvent{Type: "error", Message: "Timeout waiting for authentication"}) - return fmt.Errorf("timeout waiting for authentication to complete") - } - - resp, err := c.invocations.Get(ctx, invocation.InvocationID) - if err != nil { - return util.CleanedUpSdkError{Err: err} - } - - // Emit status update if step changed - if string(resp.Step) != lastStep { - lastStep = string(resp.Step) - emitEvent(AgentAuthRunEvent{ - Type: "status", - Step: lastStep, - Status: string(resp.Status), - LiveViewURL: resp.LiveViewURL, - }) - if !jsonOutput { - pterm.Info.Printf("Step: %s (Status: %s)\n", resp.Step, resp.Status) - } - } - - // Check terminal states - switch resp.Status { - case kernel.AgentAuthInvocationResponseStatusSuccess: - if !jsonOutput { - pterm.Success.Println("Authentication successful!") - pterm.Success.Printf("Profile '%s' is now authenticated for %s\n", in.ProfileName, in.Domain) - } - result := AgentAuthRunResult{ - ProfileName: in.ProfileName, - ProfileID: profileID, - Domain: in.Domain, - AuthAgentID: agent.ID, - } - if jsonOutput { - emitEvent(AgentAuthRunEvent{Type: "success", Message: "Authentication successful"}) - data, err := json.MarshalIndent(result, "", " ") - if err != nil { - return err - } - fmt.Println(string(data)) - return nil - } - return nil - - case kernel.AgentAuthInvocationResponseStatusFailed: - errMsg := "Authentication failed" - if resp.ErrorMessage != "" { - errMsg = resp.ErrorMessage - } - emitEvent(AgentAuthRunEvent{Type: "error", Message: errMsg}) - return fmt.Errorf("authentication failed: %s", errMsg) - - case kernel.AgentAuthInvocationResponseStatusExpired: - emitEvent(AgentAuthRunEvent{Type: "error", Message: "Authentication session expired"}) - return fmt.Errorf("authentication session expired") - - case kernel.AgentAuthInvocationResponseStatusCanceled: - emitEvent(AgentAuthRunEvent{Type: "error", Message: "Authentication was canceled"}) - return fmt.Errorf("authentication was canceled") - } - - // Handle awaiting_input step - if resp.Step == kernel.AgentAuthInvocationResponseStepAwaitingInput { - // Check for pending fields - if len(resp.PendingFields) > 0 { - // Build field values to submit - submitValues := make(map[string]string) - missingFields := []string{} - - for _, field := range resp.PendingFields { - fieldName := field.Name - // Check if we already submitted this field - if fieldsSubmitted[fieldName] { - continue - } - - // Try to find a matching value - if val, ok := in.Values[fieldName]; ok { - submitValues[fieldName] = val - } else { - // Check common field name aliases - matched := false - aliases := map[string][]string{ - "identifier": {"username", "email", "login"}, - "username": {"identifier", "email", "login"}, - "email": {"identifier", "username", "login"}, - "password": {"pass", "passwd"}, - } - if alts, ok := aliases[fieldName]; ok { - for _, alt := range alts { - if val, ok := in.Values[alt]; ok { - submitValues[fieldName] = val - matched = true - break - } - } - } - - // Check if this looks like a TOTP/verification code field - if !matched && in.TotpSecret != "" { - fieldLower := strings.ToLower(fieldName) - totpPatterns := []string{"totp", "code", "verification", "otp", "2fa", "mfa", "authenticator", "token"} - for _, pattern := range totpPatterns { - if strings.Contains(fieldLower, pattern) { - code, err := generateTOTPCode(in.TotpSecret, jsonOutput) - if err == nil { - submitValues[fieldName] = code - matched = true - if !jsonOutput { - pterm.Info.Printf("Generated TOTP code for field: %s\n", fieldName) - } - } - break - } - } - } - - if !matched { - missingFields = append(missingFields, fieldName) - } - } - } - - // Submit if we have values - if len(submitValues) > 0 { - if !jsonOutput { - var fieldNames []string - for k := range submitValues { - fieldNames = append(fieldNames, k) - } - pterm.Info.Printf("Submitting fields: %s\n", strings.Join(fieldNames, ", ")) - } - - submitParams := kernel.AgentAuthInvocationSubmitParams{ - OfFieldValues: &kernel.AgentAuthInvocationSubmitParamsBodyFieldValues{ - FieldValues: submitValues, - }, - } - _, err := c.invocations.Submit(ctx, invocation.InvocationID, submitParams) - if err != nil { - return util.CleanedUpSdkError{Err: err} - } - - // Mark fields as submitted - for k := range submitValues { - fieldsSubmitted[k] = true - } - } - - // Show live view if we have missing fields - if len(missingFields) > 0 && !liveViewShown && resp.LiveViewURL != "" { - liveViewShown = true - emitEvent(AgentAuthRunEvent{ - Type: "waiting", - Message: fmt.Sprintf("Need human input for: %s", strings.Join(missingFields, ", ")), - LiveViewURL: resp.LiveViewURL, - }) - if !jsonOutput { - pterm.Warning.Printf("Missing values for fields: %s\n", strings.Join(missingFields, ", ")) - pterm.Info.Printf("Live view: %s\n", resp.LiveViewURL) - } - if in.OpenLiveView { - _ = browser.OpenURL(resp.LiveViewURL) - } - } - } - - // Check for MFA options - if len(resp.MfaOptions) > 0 { - // Check if TOTP is available and we have a secret - hasTOTP := false - for _, opt := range resp.MfaOptions { - if opt.Type == "totp" { - hasTOTP = true - break - } - } - - if hasTOTP && in.TotpSecret != "" { - // Generate and submit TOTP code - code, err := generateTOTPCode(in.TotpSecret, jsonOutput) - if err != nil { - return err - } - - if !jsonOutput { - pterm.Info.Println("Submitting TOTP code...") - } - - submitParams := kernel.AgentAuthInvocationSubmitParams{ - OfFieldValues: &kernel.AgentAuthInvocationSubmitParamsBodyFieldValues{ - FieldValues: map[string]string{"totp": code}, - }, - } - _, err = c.invocations.Submit(ctx, invocation.InvocationID, submitParams) - if err != nil { - return util.CleanedUpSdkError{Err: err} - } - } else if !liveViewShown && resp.LiveViewURL != "" { - // Need human for MFA - liveViewShown = true - var optTypes []string - for _, opt := range resp.MfaOptions { - optTypes = append(optTypes, opt.Type) - } - emitEvent(AgentAuthRunEvent{ - Type: "waiting", - Message: fmt.Sprintf("MFA required: %s", strings.Join(optTypes, ", ")), - LiveViewURL: resp.LiveViewURL, - }) - if !jsonOutput { - pterm.Warning.Printf("MFA required. Options: %s\n", strings.Join(optTypes, ", ")) - pterm.Info.Printf("Complete MFA at: %s\n", resp.LiveViewURL) - } - if in.OpenLiveView { - _ = browser.OpenURL(resp.LiveViewURL) - } - } - } - } - - // Handle awaiting_external_action step - if resp.Step == kernel.AgentAuthInvocationResponseStepAwaitingExternalAction && !liveViewShown { - liveViewShown = true - msg := "External action required" - if resp.ExternalActionMessage != "" { - msg = resp.ExternalActionMessage - } - emitEvent(AgentAuthRunEvent{ - Type: "waiting", - Message: msg, - LiveViewURL: resp.LiveViewURL, - }) - if !jsonOutput { - pterm.Warning.Printf("%s\n", msg) - if resp.LiveViewURL != "" { - pterm.Info.Printf("Live view: %s\n", resp.LiveViewURL) - } - } - if in.OpenLiveView && resp.LiveViewURL != "" { - _ = browser.OpenURL(resp.LiveViewURL) - } - } - - // Wait before next poll - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(pollInterval): - } - } -} - -// --- Cobra wiring --- - -var agentsCmd = &cobra.Command{ - Use: "agents", - Short: "Manage agents", - Long: "Commands for managing Kernel agents (auth, etc.)", -} - -var agentsAuthCmd = &cobra.Command{ - Use: "auth", - Short: "Manage auth agents", - Long: "Commands for managing authentication agents that handle login flows", -} - -var agentsAuthCreateCmd = &cobra.Command{ - Use: "create", - Short: "Create an auth agent", - Long: "Create or find an auth agent for a specific domain and profile combination", - Args: cobra.NoArgs, - RunE: runAgentsAuthCreate, -} - -var agentsAuthGetCmd = &cobra.Command{ - Use: "get ", - Short: "Get an auth agent by ID", - Args: cobra.ExactArgs(1), - RunE: runAgentsAuthGet, -} - -var agentsAuthListCmd = &cobra.Command{ - Use: "list", - Short: "List auth agents", - Args: cobra.NoArgs, - RunE: runAgentsAuthList, -} - -var agentsAuthDeleteCmd = &cobra.Command{ - Use: "delete ", - Short: "Delete an auth agent", - Args: cobra.ExactArgs(1), - RunE: runAgentsAuthDelete, -} - -var agentsAuthInvocationsCmd = &cobra.Command{ - Use: "invocations", - Short: "Manage auth invocations", - Long: "Commands for managing authentication invocations (login flows)", -} - -var agentsAuthInvocationsCreateCmd = &cobra.Command{ - Use: "create", - Short: "Create an auth invocation", - Long: "Start a new authentication flow for an auth agent", - Args: cobra.NoArgs, - RunE: runAgentsAuthInvocationsCreate, -} - -var agentsAuthInvocationsGetCmd = &cobra.Command{ - Use: "get ", - Short: "Get an auth invocation", - Args: cobra.ExactArgs(1), - RunE: runAgentsAuthInvocationsGet, -} - -var agentsAuthInvocationsExchangeCmd = &cobra.Command{ - Use: "exchange ", - Short: "Exchange a handoff code for a JWT", - Args: cobra.ExactArgs(1), - RunE: runAgentsAuthInvocationsExchange, -} - -var agentsAuthInvocationsSubmitCmd = &cobra.Command{ - Use: "submit ", - Short: "Submit field values to an invocation", - Long: `Submit field values, SSO button click, or MFA selection to an auth invocation. - -Examples: - # Submit field values - kernel agents auth invocations submit --field username=myuser --field password=mypass - - # Click an SSO button - kernel agents auth invocations submit --sso-button "//button[@id='google-sso']" - - # Select an MFA method - kernel agents auth invocations submit --mfa-type sms`, - Args: cobra.ExactArgs(1), - RunE: runAgentsAuthInvocationsSubmit, -} - -var agentsAuthRunCmd = &cobra.Command{ - Use: "run", - Short: "Run a complete auth flow", - Long: `Run a complete authentication flow for a domain, automatically handling credential submission and polling. - -This command orchestrates the entire agent auth process: -1. Creates or finds a profile with the given name -2. Creates a credential if --save-credential-as is specified -3. Creates an auth agent linking domain, profile, and credential -4. Starts an invocation and polls until completion -5. Auto-submits credentials when prompted -6. Auto-submits TOTP codes if --totp-secret is provided -7. Shows live view URL when human intervention is needed - -Examples: - # Basic auth with inline credentials - kernel agents auth run --domain github.com --profile my-github \ - --value username=myuser --value password=mypass - - # With TOTP for automatic 2FA - kernel agents auth run --domain github.com --profile my-github \ - --value username=myuser --value password=mypass \ - --totp-secret JBSWY3DPEHPK3PXP - - # Save credentials for future re-auth - kernel agents auth run --domain github.com --profile my-github \ - --value username=myuser --value password=mypass \ - --save-credential-as github-creds - - # Re-use existing saved credential - kernel agents auth run --domain github.com --profile my-github \ - --credential github-creds - - # Auto-open browser for human intervention - kernel agents auth run --domain github.com --profile my-github \ - --credential github-creds --open`, - Args: cobra.NoArgs, - RunE: runAgentsAuthRun, -} - -func init() { - // Auth create flags - agentsAuthCreateCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") - agentsAuthCreateCmd.Flags().String("domain", "", "Target domain for authentication (required)") - agentsAuthCreateCmd.Flags().String("profile-name", "", "Name of the profile to use (required)") - agentsAuthCreateCmd.Flags().String("credential-name", "", "Optional credential name to link for auto-fill") - agentsAuthCreateCmd.Flags().String("login-url", "", "Optional login page URL") - agentsAuthCreateCmd.Flags().StringSlice("allowed-domain", []string{}, "Additional allowed domains (repeatable)") - agentsAuthCreateCmd.Flags().String("proxy-id", "", "Optional proxy ID to use") - _ = agentsAuthCreateCmd.MarkFlagRequired("domain") - _ = agentsAuthCreateCmd.MarkFlagRequired("profile-name") - - // Auth get flags - agentsAuthGetCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") - - // Auth list flags - agentsAuthListCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") - agentsAuthListCmd.Flags().String("domain", "", "Filter by domain") - agentsAuthListCmd.Flags().String("profile-name", "", "Filter by profile name") - agentsAuthListCmd.Flags().Int("limit", 0, "Maximum number of results to return") - agentsAuthListCmd.Flags().Int("offset", 0, "Number of results to skip") - - // Auth delete flags - agentsAuthDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") - - // Invocations create flags - agentsAuthInvocationsCreateCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") - agentsAuthInvocationsCreateCmd.Flags().String("auth-agent-id", "", "ID of the auth agent (required)") - agentsAuthInvocationsCreateCmd.Flags().String("save-credential-as", "", "Save credentials under this name on success") - _ = agentsAuthInvocationsCreateCmd.MarkFlagRequired("auth-agent-id") - - // Invocations get flags - agentsAuthInvocationsGetCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") - - // Invocations exchange flags - agentsAuthInvocationsExchangeCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") - agentsAuthInvocationsExchangeCmd.Flags().String("code", "", "Handoff code from the start endpoint (required)") - _ = agentsAuthInvocationsExchangeCmd.MarkFlagRequired("code") - - // Invocations submit flags - agentsAuthInvocationsSubmitCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") - agentsAuthInvocationsSubmitCmd.Flags().StringArray("field", []string{}, "Field name=value pair (repeatable)") - agentsAuthInvocationsSubmitCmd.Flags().String("sso-button", "", "Selector of SSO button to click") - agentsAuthInvocationsSubmitCmd.Flags().String("mfa-type", "", "MFA type to select (sms, call, email, totp, push, security_key)") - - // Auth run flags - agentsAuthRunCmd.Flags().StringP("output", "o", "", "Output format: json for JSONL events") - agentsAuthRunCmd.Flags().String("domain", "", "Target domain for authentication (required)") - agentsAuthRunCmd.Flags().String("profile", "", "Profile name to use/create (required)") - agentsAuthRunCmd.Flags().StringArray("value", []string{}, "Field name=value pair (e.g., --value username=foo --value password=bar)") - agentsAuthRunCmd.Flags().String("credential", "", "Existing credential name to use") - agentsAuthRunCmd.Flags().String("save-credential-as", "", "Save provided credentials under this name") - agentsAuthRunCmd.Flags().String("totp-secret", "", "Base32 TOTP secret for automatic 2FA") - agentsAuthRunCmd.Flags().String("proxy-id", "", "Proxy ID to use") - agentsAuthRunCmd.Flags().String("login-url", "", "Custom login page URL") - agentsAuthRunCmd.Flags().StringSlice("allowed-domain", []string{}, "Additional allowed domains") - agentsAuthRunCmd.Flags().Duration("timeout", 5*time.Minute, "Maximum time to wait for auth completion") - agentsAuthRunCmd.Flags().Bool("open", false, "Open live view URL in browser when human intervention needed") - _ = agentsAuthRunCmd.MarkFlagRequired("domain") - _ = agentsAuthRunCmd.MarkFlagRequired("profile") - - // Wire up commands - agentsAuthInvocationsCmd.AddCommand(agentsAuthInvocationsCreateCmd) - agentsAuthInvocationsCmd.AddCommand(agentsAuthInvocationsGetCmd) - agentsAuthInvocationsCmd.AddCommand(agentsAuthInvocationsExchangeCmd) - agentsAuthInvocationsCmd.AddCommand(agentsAuthInvocationsSubmitCmd) - - agentsAuthCmd.AddCommand(agentsAuthCreateCmd) - agentsAuthCmd.AddCommand(agentsAuthGetCmd) - agentsAuthCmd.AddCommand(agentsAuthListCmd) - agentsAuthCmd.AddCommand(agentsAuthDeleteCmd) - agentsAuthCmd.AddCommand(agentsAuthInvocationsCmd) - agentsAuthCmd.AddCommand(agentsAuthRunCmd) - - agentsCmd.AddCommand(agentsAuthCmd) -} - -func runAgentsAuthCreate(cmd *cobra.Command, args []string) error { - client := getKernelClient(cmd) - output, _ := cmd.Flags().GetString("output") - domain, _ := cmd.Flags().GetString("domain") - profileName, _ := cmd.Flags().GetString("profile-name") - credentialName, _ := cmd.Flags().GetString("credential-name") - loginURL, _ := cmd.Flags().GetString("login-url") - allowedDomains, _ := cmd.Flags().GetStringSlice("allowed-domain") - proxyID, _ := cmd.Flags().GetString("proxy-id") - - svc := client.Agents.Auth - c := AgentAuthCmd{auth: &svc, invocations: &svc.Invocations} - return c.Create(cmd.Context(), AgentAuthCreateInput{ - Domain: domain, - ProfileName: profileName, - CredentialName: credentialName, - LoginURL: loginURL, - AllowedDomains: allowedDomains, - ProxyID: proxyID, - Output: output, - }) -} - -func runAgentsAuthGet(cmd *cobra.Command, args []string) error { - client := getKernelClient(cmd) - output, _ := cmd.Flags().GetString("output") - - svc := client.Agents.Auth - c := AgentAuthCmd{auth: &svc, invocations: &svc.Invocations} - return c.Get(cmd.Context(), AgentAuthGetInput{ - ID: args[0], - Output: output, - }) -} - -func runAgentsAuthList(cmd *cobra.Command, args []string) error { - client := getKernelClient(cmd) - output, _ := cmd.Flags().GetString("output") - domain, _ := cmd.Flags().GetString("domain") - profileName, _ := cmd.Flags().GetString("profile-name") - limit, _ := cmd.Flags().GetInt("limit") - offset, _ := cmd.Flags().GetInt("offset") - - svc := client.Agents.Auth - c := AgentAuthCmd{auth: &svc, invocations: &svc.Invocations} - return c.List(cmd.Context(), AgentAuthListInput{ - Domain: domain, - ProfileName: profileName, - Limit: limit, - Offset: offset, - Output: output, - }) -} - -func runAgentsAuthDelete(cmd *cobra.Command, args []string) error { - client := getKernelClient(cmd) - skip, _ := cmd.Flags().GetBool("yes") - - svc := client.Agents.Auth - c := AgentAuthCmd{auth: &svc, invocations: &svc.Invocations} - return c.Delete(cmd.Context(), AgentAuthDeleteInput{ - ID: args[0], - SkipConfirm: skip, - }) -} - -func runAgentsAuthInvocationsCreate(cmd *cobra.Command, args []string) error { - client := getKernelClient(cmd) - output, _ := cmd.Flags().GetString("output") - authAgentID, _ := cmd.Flags().GetString("auth-agent-id") - saveCredentialAs, _ := cmd.Flags().GetString("save-credential-as") - - svc := client.Agents.Auth - c := AgentAuthCmd{auth: &svc, invocations: &svc.Invocations} - return c.InvocationCreate(cmd.Context(), AgentAuthInvocationCreateInput{ - AuthAgentID: authAgentID, - SaveCredentialAs: saveCredentialAs, - Output: output, - }) -} - -func runAgentsAuthInvocationsGet(cmd *cobra.Command, args []string) error { - client := getKernelClient(cmd) - output, _ := cmd.Flags().GetString("output") - - svc := client.Agents.Auth - c := AgentAuthCmd{auth: &svc, invocations: &svc.Invocations} - return c.InvocationGet(cmd.Context(), AgentAuthInvocationGetInput{ - InvocationID: args[0], - Output: output, - }) -} - -func runAgentsAuthInvocationsExchange(cmd *cobra.Command, args []string) error { - client := getKernelClient(cmd) - output, _ := cmd.Flags().GetString("output") - code, _ := cmd.Flags().GetString("code") - - svc := client.Agents.Auth - c := AgentAuthCmd{auth: &svc, invocations: &svc.Invocations} - return c.InvocationExchange(cmd.Context(), AgentAuthInvocationExchangeInput{ - InvocationID: args[0], - Code: code, - Output: output, - }) -} - -func runAgentsAuthInvocationsSubmit(cmd *cobra.Command, args []string) error { - client := getKernelClient(cmd) - output, _ := cmd.Flags().GetString("output") - fieldPairs, _ := cmd.Flags().GetStringArray("field") - ssoButton, _ := cmd.Flags().GetString("sso-button") - mfaType, _ := cmd.Flags().GetString("mfa-type") - - // Parse field pairs into map - fieldValues := make(map[string]string) - for _, pair := range fieldPairs { - parts := strings.SplitN(pair, "=", 2) - if len(parts) != 2 { - return fmt.Errorf("invalid field format: %s (expected key=value)", pair) - } - fieldValues[parts[0]] = parts[1] - } - - svc := client.Agents.Auth - c := AgentAuthCmd{auth: &svc, invocations: &svc.Invocations} - return c.InvocationSubmit(cmd.Context(), AgentAuthInvocationSubmitInput{ - InvocationID: args[0], - FieldValues: fieldValues, - SSOButton: ssoButton, - SelectedMfaType: mfaType, - Output: output, - }) -} - -func runAgentsAuthRun(cmd *cobra.Command, args []string) error { - client := getKernelClient(cmd) - - output, _ := cmd.Flags().GetString("output") - domain, _ := cmd.Flags().GetString("domain") - profileName, _ := cmd.Flags().GetString("profile") - valuePairs, _ := cmd.Flags().GetStringArray("value") - credentialName, _ := cmd.Flags().GetString("credential") - saveCredentialAs, _ := cmd.Flags().GetString("save-credential-as") - totpSecret, _ := cmd.Flags().GetString("totp-secret") - proxyID, _ := cmd.Flags().GetString("proxy-id") - loginURL, _ := cmd.Flags().GetString("login-url") - allowedDomains, _ := cmd.Flags().GetStringSlice("allowed-domain") - timeout, _ := cmd.Flags().GetDuration("timeout") - openLiveView, _ := cmd.Flags().GetBool("open") - - // Parse value pairs into map - values := make(map[string]string) - for _, pair := range valuePairs { - parts := strings.SplitN(pair, "=", 2) - if len(parts) != 2 { - return fmt.Errorf("invalid value format: %s (expected key=value)", pair) - } - values[parts[0]] = parts[1] - } - - authSvc := client.Agents.Auth - profilesSvc := client.Profiles - credentialsSvc := client.Credentials - - c := AgentAuthRunCmd{ - auth: &authSvc, - invocations: &authSvc.Invocations, - profiles: &profilesSvc, - credentials: &credentialsSvc, - } - - return c.Run(cmd.Context(), AgentAuthRunInput{ - Domain: domain, - ProfileName: profileName, - Values: values, - CredentialName: credentialName, - SaveCredentialAs: saveCredentialAs, - TotpSecret: totpSecret, - ProxyID: proxyID, - LoginURL: loginURL, - AllowedDomains: allowedDomains, - Timeout: timeout, - OpenLiveView: openLiveView, - Output: output, - }) -} diff --git a/cmd/auth_connections.go b/cmd/auth_connections.go new file mode 100644 index 0000000..5ff1e31 --- /dev/null +++ b/cmd/auth_connections.go @@ -0,0 +1,741 @@ +package cmd + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/kernel/cli/pkg/util" + "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk/packages/pagination" + "github.com/kernel/kernel-go-sdk/packages/ssestream" + "github.com/pterm/pterm" + "github.com/spf13/cobra" +) + +// AuthConnectionService defines the subset of the Kernel SDK auth connection client that we use. +type AuthConnectionService interface { + New(ctx context.Context, body kernel.AuthConnectionNewParams, opts ...option.RequestOption) (res *kernel.ManagedAuth, err error) + Get(ctx context.Context, id string, opts ...option.RequestOption) (res *kernel.ManagedAuth, err error) + List(ctx context.Context, query kernel.AuthConnectionListParams, opts ...option.RequestOption) (res *pagination.OffsetPagination[kernel.ManagedAuth], err error) + Delete(ctx context.Context, id string, opts ...option.RequestOption) (err error) + Login(ctx context.Context, id string, body kernel.AuthConnectionLoginParams, opts ...option.RequestOption) (res *kernel.LoginResponse, err error) + Submit(ctx context.Context, id string, body kernel.AuthConnectionSubmitParams, opts ...option.RequestOption) (res *kernel.SubmitFieldsResponse, err error) + FollowStreaming(ctx context.Context, id string, opts ...option.RequestOption) (stream *ssestream.Stream[kernel.AuthConnectionFollowResponseUnion]) +} + +// AuthConnectionCmd handles auth connection operations independent of cobra. +type AuthConnectionCmd struct { + svc AuthConnectionService +} + +type AuthConnectionCreateInput struct { + Domain string + ProfileName string + LoginURL string + AllowedDomains []string + CredentialName string + CredentialProvider string + CredentialPath string + CredentialAuto bool + ProxyID string + ProxyName string + SaveCredentials bool + NoSaveCredentials bool + HealthCheckInterval int + Output string +} + +type AuthConnectionGetInput struct { + ID string + Output string +} + +type AuthConnectionListInput struct { + Domain string + ProfileName string + Limit int + Offset int + Output string +} + +type AuthConnectionDeleteInput struct { + ID string + SkipConfirm bool +} + +type AuthConnectionLoginInput struct { + ID string + ProxyID string + ProxyName string + Output string +} + +type AuthConnectionSubmitInput struct { + ID string + FieldValues map[string]string + MfaOptionID string + SSOButtonSelector string + Output string +} + +type AuthConnectionFollowInput struct { + ID string + Output string +} + +func (c AuthConnectionCmd) Create(ctx context.Context, in AuthConnectionCreateInput) error { + if in.Output != "" && in.Output != "json" { + return fmt.Errorf("unsupported --output value: use 'json'") + } + + if in.Domain == "" { + return fmt.Errorf("--domain is required") + } + if in.ProfileName == "" { + return fmt.Errorf("--profile-name is required") + } + + params := kernel.AuthConnectionNewParams{ + ManagedAuthCreateRequest: kernel.ManagedAuthCreateRequestParam{ + Domain: in.Domain, + ProfileName: in.ProfileName, + }, + } + if in.LoginURL != "" { + params.ManagedAuthCreateRequest.LoginURL = kernel.Opt(in.LoginURL) + } + if len(in.AllowedDomains) > 0 { + params.ManagedAuthCreateRequest.AllowedDomains = in.AllowedDomains + } + if in.HealthCheckInterval > 0 { + params.ManagedAuthCreateRequest.HealthCheckInterval = kernel.Opt(int64(in.HealthCheckInterval)) + } + + // Handle credential reference + if in.CredentialName != "" { + params.ManagedAuthCreateRequest.Credential = kernel.ManagedAuthCreateRequestCredentialParam{ + Name: kernel.Opt(in.CredentialName), + } + } else if in.CredentialProvider != "" { + params.ManagedAuthCreateRequest.Credential = kernel.ManagedAuthCreateRequestCredentialParam{ + Provider: kernel.Opt(in.CredentialProvider), + } + if in.CredentialPath != "" { + params.ManagedAuthCreateRequest.Credential.Path = kernel.Opt(in.CredentialPath) + } + if in.CredentialAuto { + params.ManagedAuthCreateRequest.Credential.Auto = kernel.Opt(true) + } + } + + if in.ProxyID != "" || in.ProxyName != "" { + params.ManagedAuthCreateRequest.Proxy = kernel.ManagedAuthCreateRequestProxyParam{} + if in.ProxyID != "" { + params.ManagedAuthCreateRequest.Proxy.ID = kernel.Opt(in.ProxyID) + } + if in.ProxyName != "" { + params.ManagedAuthCreateRequest.Proxy.Name = kernel.Opt(in.ProxyName) + } + } + + if in.NoSaveCredentials { + params.ManagedAuthCreateRequest.SaveCredentials = kernel.Opt(false) + } + + if in.Output != "json" { + pterm.Info.Printf("Creating managed auth for %s...\n", in.Domain) + } + + auth, err := c.svc.New(ctx, params) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if in.Output == "json" { + return util.PrintPrettyJSON(auth) + } + + pterm.Success.Printf("Created managed auth: %s\n", auth.ID) + + tableData := pterm.TableData{ + {"Property", "Value"}, + {"ID", auth.ID}, + {"Domain", auth.Domain}, + {"Profile Name", auth.ProfileName}, + {"Status", string(auth.Status)}, + {"Can Reauth", fmt.Sprintf("%t", auth.CanReauth)}, + } + if auth.CanReauthReason != "" { + tableData = append(tableData, []string{"Can Reauth Reason", auth.CanReauthReason}) + } + if auth.Credential.Name != "" { + tableData = append(tableData, []string{"Credential Name", auth.Credential.Name}) + } + if auth.Credential.Provider != "" { + tableData = append(tableData, []string{"Credential Provider", auth.Credential.Provider}) + } + + PrintTableNoPad(tableData, true) + return nil +} + +func (c AuthConnectionCmd) Get(ctx context.Context, in AuthConnectionGetInput) error { + if in.Output != "" && in.Output != "json" { + return fmt.Errorf("unsupported --output value: use 'json'") + } + + auth, err := c.svc.Get(ctx, in.ID) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if in.Output == "json" { + return util.PrintPrettyJSON(auth) + } + + tableData := pterm.TableData{ + {"Property", "Value"}, + {"ID", auth.ID}, + {"Domain", auth.Domain}, + {"Profile Name", auth.ProfileName}, + {"Status", string(auth.Status)}, + {"Can Reauth", fmt.Sprintf("%t", auth.CanReauth)}, + } + if auth.CanReauthReason != "" { + tableData = append(tableData, []string{"Can Reauth Reason", auth.CanReauthReason}) + } + if auth.Credential.Name != "" { + tableData = append(tableData, []string{"Credential Name", auth.Credential.Name}) + } + if auth.Credential.Provider != "" { + tableData = append(tableData, []string{"Credential Provider", auth.Credential.Provider}) + } + if auth.FlowStatus != "" { + tableData = append(tableData, []string{"Flow Status", string(auth.FlowStatus)}) + } + if auth.FlowStep != "" { + tableData = append(tableData, []string{"Flow Step", string(auth.FlowStep)}) + } + if auth.HostedURL != "" { + tableData = append(tableData, []string{"Hosted URL", auth.HostedURL}) + } + if auth.LiveViewURL != "" { + tableData = append(tableData, []string{"Live View URL", auth.LiveViewURL}) + } + if auth.ErrorMessage != "" { + tableData = append(tableData, []string{"Error Message", auth.ErrorMessage}) + } + if !auth.LastAuthAt.IsZero() { + tableData = append(tableData, []string{"Last Auth At", util.FormatLocal(auth.LastAuthAt)}) + } + if len(auth.AllowedDomains) > 0 { + tableData = append(tableData, []string{"Allowed Domains", strings.Join(auth.AllowedDomains, ", ")}) + } + if auth.HealthCheckInterval > 0 { + tableData = append(tableData, []string{"Health Check Interval", fmt.Sprintf("%d seconds", auth.HealthCheckInterval)}) + } + + PrintTableNoPad(tableData, true) + return nil +} + +func (c AuthConnectionCmd) List(ctx context.Context, in AuthConnectionListInput) error { + if in.Output != "" && in.Output != "json" { + return fmt.Errorf("unsupported --output value: use 'json'") + } + + params := kernel.AuthConnectionListParams{} + if in.Domain != "" { + params.Domain = kernel.Opt(in.Domain) + } + if in.ProfileName != "" { + params.ProfileName = kernel.Opt(in.ProfileName) + } + if in.Limit > 0 { + params.Limit = kernel.Opt(int64(in.Limit)) + } + if in.Offset > 0 { + params.Offset = kernel.Opt(int64(in.Offset)) + } + + page, err := c.svc.List(ctx, params) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + var auths []kernel.ManagedAuth + if page != nil { + auths = page.Items + } + + if in.Output == "json" { + if len(auths) == 0 { + fmt.Println("[]") + return nil + } + return util.PrintPrettyJSONSlice(auths) + } + + if len(auths) == 0 { + pterm.Info.Println("No managed auths found") + return nil + } + + tableData := pterm.TableData{{"ID", "Domain", "Profile Name", "Status", "Can Reauth"}} + for _, auth := range auths { + tableData = append(tableData, []string{ + auth.ID, + auth.Domain, + auth.ProfileName, + string(auth.Status), + fmt.Sprintf("%t", auth.CanReauth), + }) + } + + PrintTableNoPad(tableData, true) + return nil +} + +func (c AuthConnectionCmd) Delete(ctx context.Context, in AuthConnectionDeleteInput) error { + if !in.SkipConfirm { + msg := fmt.Sprintf("Are you sure you want to delete managed auth '%s'?", in.ID) + pterm.DefaultInteractiveConfirm.DefaultText = msg + ok, _ := pterm.DefaultInteractiveConfirm.Show() + if !ok { + pterm.Info.Println("Deletion cancelled") + return nil + } + } + + if err := c.svc.Delete(ctx, in.ID); err != nil { + if util.IsNotFound(err) { + pterm.Info.Printf("Managed auth '%s' not found\n", in.ID) + return nil + } + return util.CleanedUpSdkError{Err: err} + } + pterm.Success.Printf("Deleted managed auth: %s\n", in.ID) + return nil +} + +func (c AuthConnectionCmd) Login(ctx context.Context, in AuthConnectionLoginInput) error { + if in.Output != "" && in.Output != "json" { + return fmt.Errorf("unsupported --output value: use 'json'") + } + + params := kernel.AuthConnectionLoginParams{} + if in.ProxyID != "" || in.ProxyName != "" { + params.Proxy = kernel.AuthConnectionLoginParamsProxy{} + if in.ProxyID != "" { + params.Proxy.ID = kernel.Opt(in.ProxyID) + } + if in.ProxyName != "" { + params.Proxy.Name = kernel.Opt(in.ProxyName) + } + } + + if in.Output != "json" { + pterm.Info.Println("Starting login flow...") + } + + resp, err := c.svc.Login(ctx, in.ID, params) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if in.Output == "json" { + return util.PrintPrettyJSON(resp) + } + + pterm.Success.Printf("Login flow started: %s\n", resp.FlowType) + + tableData := pterm.TableData{ + {"Property", "Value"}, + {"ID", resp.ID}, + {"Flow Type", string(resp.FlowType)}, + {"Hosted URL", resp.HostedURL}, + {"Flow Expires At", util.FormatLocal(resp.FlowExpiresAt)}, + } + if resp.LiveViewURL != "" { + tableData = append(tableData, []string{"Live View URL", resp.LiveViewURL}) + } + + PrintTableNoPad(tableData, true) + return nil +} + +func (c AuthConnectionCmd) Submit(ctx context.Context, in AuthConnectionSubmitInput) error { + if in.Output != "" && in.Output != "json" { + return fmt.Errorf("unsupported --output value: use 'json'") + } + + // Validate that we have some input to submit + hasFields := len(in.FieldValues) > 0 + hasMfaOption := in.MfaOptionID != "" + hasSSOButton := in.SSOButtonSelector != "" + + if !hasFields && !hasMfaOption && !hasSSOButton { + return fmt.Errorf("must provide at least one of: --field, --mfa-option-id, or --sso-button-selector") + } + + params := kernel.AuthConnectionSubmitParams{ + SubmitFieldsRequest: kernel.SubmitFieldsRequestParam{ + Fields: in.FieldValues, + }, + } + if hasMfaOption { + params.SubmitFieldsRequest.MfaOptionID = kernel.Opt(in.MfaOptionID) + } + if hasSSOButton { + params.SubmitFieldsRequest.SSOButtonSelector = kernel.Opt(in.SSOButtonSelector) + } + + if in.Output != "json" { + pterm.Info.Println("Submitting to managed auth...") + } + + resp, err := c.svc.Submit(ctx, in.ID, params) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if in.Output == "json" { + return util.PrintPrettyJSON(resp) + } + + if resp.Accepted { + pterm.Success.Println("Submission accepted") + } else { + pterm.Warning.Println("Submission not accepted") + } + return nil +} + +func (c AuthConnectionCmd) Follow(ctx context.Context, in AuthConnectionFollowInput) error { + if in.Output != "" && in.Output != "json" { + return fmt.Errorf("unsupported --output value: use 'json'") + } + + stream := c.svc.FollowStreaming(ctx, in.ID) + if stream == nil { + return fmt.Errorf("failed to establish SSE stream") + } + defer stream.Close() + + if in.Output != "json" { + pterm.Info.Println("Following managed auth events (Ctrl+C to stop)...") + } + + for stream.Next() { + event := stream.Current() + + if in.Output == "json" { + if err := util.PrintPrettyJSON(event); err != nil { + return err + } + continue + } + + // Human-readable output + switch event.Event { + case "managed_auth_state": + state := event.AsManagedAuthState() + pterm.Info.Printf("[%s] Status: %s, Step: %s\n", + state.Timestamp.Local().Format(time.RFC3339), + state.FlowStatus, + state.FlowStep) + if len(state.DiscoveredFields) > 0 { + var fieldNames []string + for _, f := range state.DiscoveredFields { + fieldNames = append(fieldNames, f.Name) + } + pterm.Info.Printf(" Discovered fields: %s\n", strings.Join(fieldNames, ", ")) + } + if state.ErrorMessage != "" { + pterm.Error.Printf(" Error: %s\n", state.ErrorMessage) + } + if state.WebsiteError != "" { + pterm.Warning.Printf(" Website error: %s\n", state.WebsiteError) + } + case "error": + errEvent := event.AsError() + pterm.Error.Printf("Error: %s\n", errEvent.Error.Message) + case "sse_heartbeat": + // Silently ignore heartbeats for human-readable output + } + } + + if err := stream.Err(); err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if in.Output != "json" { + pterm.Success.Println("Stream ended") + } + return nil +} + +// --- Cobra wiring --- + +var authConnectionsCmd = &cobra.Command{ + Use: "connections", + Short: "Manage auth connections (managed auth)", + Long: "Commands for managing authentication connections that keep profiles logged into domains", +} + +var authConnectionsCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a managed auth connection", + Long: "Create managed authentication for a profile and domain combination", + Args: cobra.NoArgs, + RunE: runAuthConnectionsCreate, +} + +var authConnectionsGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get a managed auth by ID", + Args: cobra.ExactArgs(1), + RunE: runAuthConnectionsGet, +} + +var authConnectionsListCmd = &cobra.Command{ + Use: "list", + Short: "List managed auths", + Args: cobra.NoArgs, + RunE: runAuthConnectionsList, +} + +var authConnectionsDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a managed auth", + Args: cobra.ExactArgs(1), + RunE: runAuthConnectionsDelete, +} + +var authConnectionsLoginCmd = &cobra.Command{ + Use: "login ", + Short: "Start a login flow", + Long: "Start a login flow for the managed auth, returns a hosted URL for authentication", + Args: cobra.ExactArgs(1), + RunE: runAuthConnectionsLogin, +} + +var authConnectionsSubmitCmd = &cobra.Command{ + Use: "submit ", + Short: "Submit field values to a login flow", + Long: `Submit field values for the login form. Poll the managed auth to track progress. + +Examples: + # Submit field values + kernel auth connections submit --field username=myuser --field password=mypass + + # Select an MFA option + kernel auth connections submit --mfa-option-id + + # Click an SSO button + kernel auth connections submit --sso-button-selector "//button[@id='google-sso']"`, + Args: cobra.ExactArgs(1), + RunE: runAuthConnectionsSubmit, +} + +var authConnectionsFollowCmd = &cobra.Command{ + Use: "follow ", + Short: "Follow login flow events", + Long: "Establish an SSE stream to receive real-time login flow state updates", + Args: cobra.ExactArgs(1), + RunE: runAuthConnectionsFollow, +} + +func init() { + // Create flags + authConnectionsCreateCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") + authConnectionsCreateCmd.Flags().String("domain", "", "Target domain for authentication (required)") + authConnectionsCreateCmd.Flags().String("profile-name", "", "Name of the profile to manage (required)") + authConnectionsCreateCmd.Flags().String("login-url", "", "Optional login page URL to skip discovery") + authConnectionsCreateCmd.Flags().StringSlice("allowed-domain", []string{}, "Additional allowed domains (repeatable)") + authConnectionsCreateCmd.Flags().String("credential-name", "", "Kernel credential name to use") + authConnectionsCreateCmd.Flags().String("credential-provider", "", "External credential provider name") + authConnectionsCreateCmd.Flags().String("credential-path", "", "Provider-specific path (e.g., VaultName/ItemName)") + authConnectionsCreateCmd.Flags().Bool("credential-auto", false, "Lookup by domain from the specified provider") + authConnectionsCreateCmd.Flags().String("proxy-id", "", "Proxy ID to use") + authConnectionsCreateCmd.Flags().String("proxy-name", "", "Proxy name to use") + authConnectionsCreateCmd.Flags().Bool("no-save-credentials", false, "Disable saving credentials after successful login") + authConnectionsCreateCmd.Flags().Int("health-check-interval", 0, "Interval in seconds between health checks (300-86400)") + _ = authConnectionsCreateCmd.MarkFlagRequired("domain") + _ = authConnectionsCreateCmd.MarkFlagRequired("profile-name") + + // Get flags + authConnectionsGetCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") + + // List flags + authConnectionsListCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") + authConnectionsListCmd.Flags().String("domain", "", "Filter by domain") + authConnectionsListCmd.Flags().String("profile-name", "", "Filter by profile name") + authConnectionsListCmd.Flags().Int("limit", 0, "Maximum number of results to return") + authConnectionsListCmd.Flags().Int("offset", 0, "Number of results to skip") + + // Delete flags + authConnectionsDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") + + // Login flags + authConnectionsLoginCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") + authConnectionsLoginCmd.Flags().String("proxy-id", "", "Proxy ID to use for this login") + authConnectionsLoginCmd.Flags().String("proxy-name", "", "Proxy name to use for this login") + + // Submit flags + authConnectionsSubmitCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") + authConnectionsSubmitCmd.Flags().StringArray("field", []string{}, "Field name=value pair (repeatable)") + authConnectionsSubmitCmd.Flags().String("mfa-option-id", "", "MFA option ID if user selected an MFA method") + authConnectionsSubmitCmd.Flags().String("sso-button-selector", "", "XPath selector if user chose an SSO button") + + // Follow flags + authConnectionsFollowCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") + + // Wire up commands + authConnectionsCmd.AddCommand(authConnectionsCreateCmd) + authConnectionsCmd.AddCommand(authConnectionsGetCmd) + authConnectionsCmd.AddCommand(authConnectionsListCmd) + authConnectionsCmd.AddCommand(authConnectionsDeleteCmd) + authConnectionsCmd.AddCommand(authConnectionsLoginCmd) + authConnectionsCmd.AddCommand(authConnectionsSubmitCmd) + authConnectionsCmd.AddCommand(authConnectionsFollowCmd) + + authCmd.AddCommand(authConnectionsCmd) +} + +func runAuthConnectionsCreate(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + output, _ := cmd.Flags().GetString("output") + domain, _ := cmd.Flags().GetString("domain") + profileName, _ := cmd.Flags().GetString("profile-name") + loginURL, _ := cmd.Flags().GetString("login-url") + allowedDomains, _ := cmd.Flags().GetStringSlice("allowed-domain") + credentialName, _ := cmd.Flags().GetString("credential-name") + credentialProvider, _ := cmd.Flags().GetString("credential-provider") + credentialPath, _ := cmd.Flags().GetString("credential-path") + credentialAuto, _ := cmd.Flags().GetBool("credential-auto") + proxyID, _ := cmd.Flags().GetString("proxy-id") + proxyName, _ := cmd.Flags().GetString("proxy-name") + noSaveCredentials, _ := cmd.Flags().GetBool("no-save-credentials") + healthCheckInterval, _ := cmd.Flags().GetInt("health-check-interval") + + svc := client.Auth.Connections + c := AuthConnectionCmd{svc: &svc} + return c.Create(cmd.Context(), AuthConnectionCreateInput{ + Domain: domain, + ProfileName: profileName, + LoginURL: loginURL, + AllowedDomains: allowedDomains, + CredentialName: credentialName, + CredentialProvider: credentialProvider, + CredentialPath: credentialPath, + CredentialAuto: credentialAuto, + ProxyID: proxyID, + ProxyName: proxyName, + NoSaveCredentials: noSaveCredentials, + HealthCheckInterval: healthCheckInterval, + Output: output, + }) +} + +func runAuthConnectionsGet(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + output, _ := cmd.Flags().GetString("output") + + svc := client.Auth.Connections + c := AuthConnectionCmd{svc: &svc} + return c.Get(cmd.Context(), AuthConnectionGetInput{ + ID: args[0], + Output: output, + }) +} + +func runAuthConnectionsList(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + output, _ := cmd.Flags().GetString("output") + domain, _ := cmd.Flags().GetString("domain") + profileName, _ := cmd.Flags().GetString("profile-name") + limit, _ := cmd.Flags().GetInt("limit") + offset, _ := cmd.Flags().GetInt("offset") + + svc := client.Auth.Connections + c := AuthConnectionCmd{svc: &svc} + return c.List(cmd.Context(), AuthConnectionListInput{ + Domain: domain, + ProfileName: profileName, + Limit: limit, + Offset: offset, + Output: output, + }) +} + +func runAuthConnectionsDelete(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + skip, _ := cmd.Flags().GetBool("yes") + + svc := client.Auth.Connections + c := AuthConnectionCmd{svc: &svc} + return c.Delete(cmd.Context(), AuthConnectionDeleteInput{ + ID: args[0], + SkipConfirm: skip, + }) +} + +func runAuthConnectionsLogin(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + output, _ := cmd.Flags().GetString("output") + proxyID, _ := cmd.Flags().GetString("proxy-id") + proxyName, _ := cmd.Flags().GetString("proxy-name") + + svc := client.Auth.Connections + c := AuthConnectionCmd{svc: &svc} + return c.Login(cmd.Context(), AuthConnectionLoginInput{ + ID: args[0], + ProxyID: proxyID, + ProxyName: proxyName, + Output: output, + }) +} + +func runAuthConnectionsSubmit(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + output, _ := cmd.Flags().GetString("output") + fieldPairs, _ := cmd.Flags().GetStringArray("field") + mfaOptionID, _ := cmd.Flags().GetString("mfa-option-id") + ssoButtonSelector, _ := cmd.Flags().GetString("sso-button-selector") + + // Parse field pairs into map + fieldValues := make(map[string]string) + for _, pair := range fieldPairs { + parts := strings.SplitN(pair, "=", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid field format: %s (expected key=value)", pair) + } + fieldValues[parts[0]] = parts[1] + } + + svc := client.Auth.Connections + c := AuthConnectionCmd{svc: &svc} + return c.Submit(cmd.Context(), AuthConnectionSubmitInput{ + ID: args[0], + FieldValues: fieldValues, + MfaOptionID: mfaOptionID, + SSOButtonSelector: ssoButtonSelector, + Output: output, + }) +} + +func runAuthConnectionsFollow(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + output, _ := cmd.Flags().GetString("output") + + svc := client.Auth.Connections + c := AuthConnectionCmd{svc: &svc} + return c.Follow(cmd.Context(), AuthConnectionFollowInput{ + ID: args[0], + Output: output, + }) +} diff --git a/cmd/browsers.go b/cmd/browsers.go index 3539dba..37f3943 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -124,9 +124,9 @@ func getAvailableViewports() []string { "1920x1080@25", "1920x1200@25", "1440x900@25", + "1280x800@60", "1024x768@60", "1200x800@60", - "1280x800@60", } } @@ -222,6 +222,7 @@ type BrowsersCmd struct { type BrowsersListInput struct { Output string IncludeDeleted bool + Status string Limit int Offset int } @@ -232,7 +233,19 @@ func (b BrowsersCmd) List(ctx context.Context, in BrowsersListInput) error { } params := kernel.BrowserListParams{} - if in.IncludeDeleted { + // Use new Status parameter if provided, otherwise fall back to deprecated IncludeDeleted + if in.Status != "" { + switch in.Status { + case "active": + params.Status = kernel.BrowserListParamsStatusActive + case "deleted": + params.Status = kernel.BrowserListParamsStatusDeleted + case "all": + params.Status = kernel.BrowserListParamsStatusAll + default: + return fmt.Errorf("invalid --status value: %s (must be 'active', 'deleted', or 'all')", in.Status) + } + } else if in.IncludeDeleted { params.IncludeDeleted = kernel.Opt(true) } if in.Limit > 0 { @@ -263,7 +276,8 @@ func (b BrowsersCmd) List(ctx context.Context, in BrowsersListInput) error { // Prepare table data headers := []string{"Browser ID", "Created At", "Persistent ID", "Profile", "CDP WS URL", "Live View URL"} - if in.IncludeDeleted { + showDeletedAt := in.IncludeDeleted || in.Status == "deleted" || in.Status == "all" + if showDeletedAt { headers = append(headers, "Deleted At") } tableData := pterm.TableData{headers} @@ -290,7 +304,7 @@ func (b BrowsersCmd) List(ctx context.Context, in BrowsersListInput) error { truncateURL(browser.BrowserLiveViewURL, 50), } - if in.IncludeDeleted { + if showDeletedAt { deletedAt := "-" if !browser.DeletedAt.IsZero() { deletedAt = util.FormatLocal(browser.DeletedAt) @@ -2053,7 +2067,8 @@ Note: Profiles can only be loaded into sessions that don't already have a profil func init() { // list flags browsersListCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") - browsersListCmd.Flags().Bool("include-deleted", false, "Include soft-deleted browser sessions in the results") + browsersListCmd.Flags().Bool("include-deleted", false, "DEPRECATED: Use --status instead. Include soft-deleted browser sessions in the results") + browsersListCmd.Flags().String("status", "", "Filter by status: 'active' (default), 'deleted', or 'all'") browsersListCmd.Flags().Int("limit", 0, "Maximum number of results to return (default 20, max 100)") browsersListCmd.Flags().Int("offset", 0, "Number of results to skip (for pagination)") @@ -2322,11 +2337,13 @@ func runBrowsersList(cmd *cobra.Command, args []string) error { b := BrowsersCmd{browsers: &svc} out, _ := cmd.Flags().GetString("output") includeDeleted, _ := cmd.Flags().GetBool("include-deleted") + status, _ := cmd.Flags().GetString("status") limit, _ := cmd.Flags().GetInt("limit") offset, _ := cmd.Flags().GetInt("offset") return b.List(cmd.Context(), BrowsersListInput{ Output: out, IncludeDeleted: includeDeleted, + Status: status, Limit: limit, Offset: offset, }) diff --git a/cmd/browsers_test.go b/cmd/browsers_test.go index 49d770d..696e29a 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -1151,8 +1151,8 @@ func TestGetAvailableViewports_ReturnsExpectedOptions(t *testing.T) { assert.Contains(t, viewports, "1920x1080@25") assert.Contains(t, viewports, "1920x1200@25") assert.Contains(t, viewports, "1440x900@25") - assert.Contains(t, viewports, "1200x800@60") assert.Contains(t, viewports, "1280x800@60") + assert.Contains(t, viewports, "1200x800@60") assert.Contains(t, viewports, "1024x768@60") } diff --git a/cmd/credential_providers.go b/cmd/credential_providers.go index 1da25f0..0ed7133 100644 --- a/cmd/credential_providers.go +++ b/cmd/credential_providers.go @@ -20,6 +20,7 @@ type CredentialProvidersService interface { List(ctx context.Context, opts ...option.RequestOption) (res *[]kernel.CredentialProvider, err error) Delete(ctx context.Context, id string, opts ...option.RequestOption) (err error) Test(ctx context.Context, id string, opts ...option.RequestOption) (res *kernel.CredentialProviderTestResult, err error) + ListItems(ctx context.Context, id string, opts ...option.RequestOption) (res *kernel.CredentialProviderListItemsResponse, err error) } // CredentialProvidersCmd handles credential provider operations independent of cobra. @@ -62,6 +63,11 @@ type CredentialProvidersTestInput struct { Output string } +type CredentialProvidersListItemsInput struct { + ID string + Output string +} + func (c CredentialProvidersCmd) List(ctx context.Context, in CredentialProvidersListInput) error { if in.Output != "" && in.Output != "json" { return fmt.Errorf("unsupported --output value: use 'json'") @@ -281,6 +287,51 @@ func (c CredentialProvidersCmd) Test(ctx context.Context, in CredentialProviders return nil } +func (c CredentialProvidersCmd) ListItems(ctx context.Context, in CredentialProvidersListItemsInput) error { + if in.Output != "" && in.Output != "json" { + return fmt.Errorf("unsupported --output value: use 'json'") + } + + if in.Output != "json" { + pterm.Info.Printf("Listing items for credential provider '%s'...\n", in.ID) + } + + result, err := c.providers.ListItems(ctx, in.ID) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if in.Output == "json" { + if len(result.Items) == 0 { + fmt.Println("[]") + return nil + } + return util.PrintPrettyJSONSlice(result.Items) + } + + if len(result.Items) == 0 { + pterm.Info.Println("No items found") + return nil + } + + tableData := pterm.TableData{{"Path", "Title", "Vault", "URLs"}} + for _, item := range result.Items { + urls := "" + if len(item.URLs) > 0 { + urls = strings.Join(item.URLs, ", ") + } + tableData = append(tableData, []string{ + item.Path, + item.Title, + item.VaultName, + urls, + }) + } + + PrintTableNoPad(tableData, true) + return nil +} + // --- Cobra wiring --- var credentialProvidersCmd = &cobra.Command{ @@ -345,6 +396,14 @@ var credentialProvidersTestCmd = &cobra.Command{ RunE: runCredentialProvidersTest, } +var credentialProvidersListItemsCmd = &cobra.Command{ + Use: "list-items ", + Short: "List items from a credential provider", + Long: `List all credential items available from the specified external credential provider.`, + Args: cobra.ExactArgs(1), + RunE: runCredentialProvidersListItems, +} + func init() { credentialProvidersCmd.AddCommand(credentialProvidersListCmd) credentialProvidersCmd.AddCommand(credentialProvidersGetCmd) @@ -352,6 +411,7 @@ func init() { credentialProvidersCmd.AddCommand(credentialProvidersUpdateCmd) credentialProvidersCmd.AddCommand(credentialProvidersDeleteCmd) credentialProvidersCmd.AddCommand(credentialProvidersTestCmd) + credentialProvidersCmd.AddCommand(credentialProvidersListItemsCmd) // List flags credentialProvidersListCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") @@ -379,6 +439,9 @@ func init() { // Test flags credentialProvidersTestCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") + + // ListItems flags + credentialProvidersListItemsCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") } func runCredentialProvidersList(cmd *cobra.Command, args []string) error { @@ -464,3 +527,15 @@ func runCredentialProvidersTest(cmd *cobra.Command, args []string) error { Output: output, }) } + +func runCredentialProvidersListItems(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + output, _ := cmd.Flags().GetString("output") + + svc := client.CredentialProviders + c := CredentialProvidersCmd{providers: &svc} + return c.ListItems(cmd.Context(), CredentialProvidersListItemsInput{ + ID: args[0], + Output: output, + }) +} diff --git a/cmd/invoke.go b/cmd/invoke.go index 4b80b67..20e0066 100644 --- a/cmd/invoke.go +++ b/cmd/invoke.go @@ -35,11 +35,20 @@ var invocationHistoryCmd = &cobra.Command{ RunE: runInvocationHistory, } +var invocationBrowsersCmd = &cobra.Command{ + Use: "browsers ", + Short: "List browser sessions for an invocation", + Long: "List all active browser sessions created within a specific invocation.", + Args: cobra.ExactArgs(1), + RunE: runInvocationBrowsers, +} + func init() { invokeCmd.Flags().StringP("version", "v", "latest", "Specify a version of the app to invoke (optional, defaults to 'latest')") invokeCmd.Flags().StringP("payload", "p", "", "JSON payload for the invocation (optional)") invokeCmd.Flags().StringP("payload-file", "f", "", "Path to a JSON file containing the payload (use '-' for stdin)") invokeCmd.Flags().BoolP("sync", "s", false, "Invoke synchronously (default false). A synchronous invocation will open a long-lived HTTP POST to the Kernel API to wait for the invocation to complete. This will time out after 60 seconds, so only use this option if you expect your invocation to complete in less than 60 seconds. The default is to invoke asynchronously, in which case the CLI will open an SSE connection to the Kernel API after submitting the invocation and wait for the invocation to complete.") + invokeCmd.Flags().Int64("async-timeout", 0, "Timeout in seconds for async invocations (min 10, max 3600). Only applies when async mode is used.") invokeCmd.Flags().StringP("output", "o", "", "Output format: json for JSONL streaming output") invokeCmd.MarkFlagsMutuallyExclusive("payload", "payload-file") @@ -48,6 +57,9 @@ func init() { invocationHistoryCmd.Flags().String("version", "", "Filter by invocation version") invocationHistoryCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") invokeCmd.AddCommand(invocationHistoryCmd) + + invocationBrowsersCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") + invokeCmd.AddCommand(invocationBrowsersCmd) } func runInvoke(cmd *cobra.Command, args []string) error { @@ -70,12 +82,16 @@ func runInvoke(cmd *cobra.Command, args []string) error { return fmt.Errorf("version cannot be an empty string") } isSync, _ := cmd.Flags().GetBool("sync") + asyncTimeout, _ := cmd.Flags().GetInt64("async-timeout") params := kernel.InvocationNewParams{ AppName: appName, ActionName: actionName, Version: version, Async: kernel.Opt(!isSync), } + if asyncTimeout > 0 { + params.AsyncTimeoutSeconds = kernel.Opt(asyncTimeout) + } payloadStr, hasPayload, err := getPayload(cmd) if err != nil { @@ -179,21 +195,21 @@ func runInvoke(cmd *cobra.Command, args []string) error { if err == nil { fmt.Println(string(bs)) } - // Check for terminal states - if ev.Event == "invocation_state" { - stateEv := ev.AsInvocationState() - status := stateEv.Invocation.Status - if status == string(kernel.InvocationGetResponseStatusSucceeded) { - return nil + // Check for terminal states + if ev.Event == "invocation_state" { + stateEv := ev.AsInvocationState() + status := stateEv.Invocation.Status + if status == string(kernel.InvocationGetResponseStatusSucceeded) { + return nil + } + if status == string(kernel.InvocationGetResponseStatusFailed) { + return fmt.Errorf("invocation failed") + } } - if status == string(kernel.InvocationGetResponseStatusFailed) { - return fmt.Errorf("invocation failed") + if ev.Event == "error" { + errEv := ev.AsError() + return fmt.Errorf("%s: %s", errEv.Error.Code, errEv.Error.Message) } - } - if ev.Event == "error" { - errEv := ev.AsError() - return fmt.Errorf("%s: %s", errEv.Error.Code, errEv.Error.Message) - } continue } @@ -428,3 +444,60 @@ func runInvocationHistory(cmd *cobra.Command, args []string) error { } return nil } + +func runInvocationBrowsers(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + invocationID := args[0] + output, _ := cmd.Flags().GetString("output") + + if output != "" && output != "json" { + return fmt.Errorf("unsupported --output value: use 'json'") + } + + resp, err := client.Invocations.ListBrowsers(cmd.Context(), invocationID) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if resp == nil { + pterm.Info.Printf("No active browsers found for invocation %s\n", invocationID) + return nil + } + + if output == "json" { + if len(resp.Browsers) == 0 { + fmt.Println("[]") + return nil + } + return util.PrintPrettyJSONSlice(resp.Browsers) + } + + if len(resp.Browsers) == 0 { + pterm.Info.Printf("No active browsers found for invocation %s\n", invocationID) + return nil + } + + table := pterm.TableData{{"Session ID", "Created At", "Headless", "Stealth", "Timeout", "CDP WS URL", "Live View URL"}} + + for _, browser := range resp.Browsers { + created := util.FormatLocal(browser.CreatedAt) + liveView := browser.BrowserLiveViewURL + if liveView == "" { + liveView = "-" + } + + table = append(table, []string{ + browser.SessionID, + created, + fmt.Sprintf("%v", browser.Headless), + fmt.Sprintf("%v", browser.Stealth), + fmt.Sprintf("%d", browser.TimeoutSeconds), + truncateURL(browser.CdpWsURL, 40), + truncateURL(liveView, 40), + }) + } + + pterm.Info.Printf("Browsers for invocation %s:\n", invocationID) + pterm.DefaultTable.WithHasHeader().WithData(table).Render() + return nil +} diff --git a/cmd/root.go b/cmd/root.go index de4b5bb..5542dcb 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -90,8 +90,11 @@ func isAuthExempt(cmd *cobra.Command) bool { // Check if the top-level command is in the exempt list switch topLevel.Name() { - case "login", "logout", "auth", "help", "completion", "create", "mcp", "upgrade": + case "login", "logout", "help", "completion", "create", "mcp", "upgrade": return true + case "auth": + // Only exempt the auth command itself (status display), not its subcommands + return cmd == topLevel } return false @@ -141,7 +144,6 @@ func init() { rootCmd.AddCommand(extensionsCmd) rootCmd.AddCommand(credentialsCmd) rootCmd.AddCommand(credentialProvidersCmd) - rootCmd.AddCommand(agentsCmd) rootCmd.AddCommand(createCmd) rootCmd.AddCommand(mcp.MCPCmd) rootCmd.AddCommand(upgradeCmd) diff --git a/go.mod b/go.mod index b415eb5..436fdb3 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/kernel/kernel-go-sdk v0.28.0 + github.com/kernel/kernel-go-sdk v0.33.0 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pquerna/otp v1.5.0 github.com/pterm/pterm v0.12.80 diff --git a/go.sum b/go.sum index 82d1c54..d254c19 100644 --- a/go.sum +++ b/go.sum @@ -66,8 +66,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.28.0 h1:cvaCWP25UIB5w6oOdQ5J+rVboNGq3VaWYhtmshlPrhg= -github.com/kernel/kernel-go-sdk v0.28.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.33.0 h1:kfk2bwrw3mbR4IW3JMnOj6Tecxor44YjM8YV153xDTY= +github.com/kernel/kernel-go-sdk v0.33.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=