diff --git a/README.org b/README.org index 54bdf571..9af38fd1 100644 --- a/README.org +++ b/README.org @@ -1089,8 +1089,18 @@ However private endpoints can be configure like so - :host "api.business.githubcopilot.com") #+end_src -You will be informed to login into =GitHub= as required. -You can pick this backend from the menu when using gptel (see [[#usage][Usage]]). +You will be informed to login into =GitHub= as required. You can pick this backend from the menu when using gptel (see [[#usage][Usage]]). + +The GitHub OAuth token is stored by default in a cleartext file based on the path stored in =gptel-gh-github-token-file=. It is possible to customize the variables =gptel-gh-github-token-load-function= and =gptel-gh-github-token-save-function= to change this behavior. If the token is e.g. stored in a password manager, then the following example can be used to so that the token is read as a password: + +#+begin_src emacs-lisp + (setq + ;; Prompt user to provide the token as a password + gptel-gh-github-token-load-function (lambda (github-username) (read-passwd (format "Copilot token for '%s': " github-username))) + + ;; Display a message when a new token has been stored, so that it can be copied from the message history + gptel-gh-github-token-save-function (lambda (github-username token) (message "New token for '%s' saved: %s" github-username token)) +#+end_src ***** (Optional) Set as the default gptel backend diff --git a/gptel-gh.el b/gptel-gh.el index 356ec120..2356394b 100644 --- a/gptel-gh.el +++ b/gptel-gh.el @@ -143,7 +143,7 @@ (cl-defstruct (gptel--gh (:include gptel-openai) (:copier nil) (:constructor gptel--make-gh)) - token github-token sessionid machineid) + token github-token sessionid machineid github-username) (defcustom gptel-gh-github-token-file (expand-file-name ".cache/copilot-chat/github-token" user-emacs-directory) @@ -151,10 +151,14 @@ :type 'string :group 'gptel) -(defcustom gptel-gh-token-file (expand-file-name ".cache/copilot-chat/token" - user-emacs-directory) - "File where the chat token is cached." - :type 'string +(defcustom gptel-gh-github-token-load-function 'gptel--gh-restore-github-token-from-file + "Function to load the current github token. Default behavior is file-based based on `gptel-gh-github-token-file'." + :type 'function + :group 'gptel) + +(defcustom gptel-gh-github-token-save-function 'gptel--gh-save-github-token-from-file + "Function to save the new github token. Default behavior is file-based based on `gptel-gh-github-token-file'." + :type 'function :group 'gptel) (defconst gptel--gh-auth-common-headers @@ -163,6 +167,33 @@ (defconst gptel--gh-client-id "Iv1.b507a08c87ecfe98") +(defun gptel--gh-get-backends-by-username (github-username) + (seq-filter (lambda (b) (and (gptel--gh-p b) + (string= (gptel--gh-github-username b) + github-username))) + (mapcar #'cdr gptel--known-backends))) + +(defun gptel--gh-load-github-token (github-username) + "Function that ensures that the GitHub OAuth token cache is used and is set." + (if (gptel--gh-github-token gptel-backend) + (gptel--gh-github-token gptel-backend) + (let ((token (funcall gptel-gh-github-token-load-function github-username))) + (if (string= token "") + ;; Empty string should be interpreted as no data. Return nil so that a + ;; proper login is performed. + nil + ;; Iterate over the known backends for the same username and set the GitHub token + (dolist (b (gptel--gh-get-backends-by-username github-username) token) + (setf (gptel--gh-github-token b) token)))))) + +(defun gptel--gh-save-github-token (github-username token) + "Function that updates the GitHub OAuth token cache and calls the save function." + ;; Update the token for all connected backends + (dolist (b (gptel--gh-get-backends-by-username github-username)) + (setf (gptel--gh-github-token b) token)) + (funcall gptel-gh-github-token-save-function github-username + (setf (gptel--gh-github-token gptel-backend) token))) + ;; https://en.wikipedia.org/wiki/Universally_unique_identifier#Version_4_(random) (defun gptel--gh-uuid () "Generate a UUID v4-1." @@ -182,7 +213,41 @@ (setq hex (nconc hex (list (aref hex-chars (random 16)))))) (apply #'string hex))) -(defun gptel--gh-restore (file) +(defun gptel--gh-validate-github-username (github-username) + "Ensures that the given GITHUB-USERNAME conforms to the rules from GitHub: + +'Username may only contain alphanumeric characters or single hyphens, +and cannot begin or end with a hyphen.' + +An empty string value is considered to be a general account to be used. +" + (cond + ;; Ensure that the username is a string + ((not (stringp github-username)) (user-error "Provided GitHub username is not string")) + + ;; Length of zero is a default value + ((= (length github-username) 0) t) + + ;; We have some characters, ensure that they conform to the rules + ((string-match-p "\\`[0-9A-Za-z]\\(?:[0-9A-Za-z]\\|-[0-9A-Za-z]\\)*\\'" github-username) t) + (t (user-error "Provided GitHub username '%s' doesn't conform to GitHub's username rules" + github-username)))) + +(defun gptel--gh-generate-github-token-filename (github-username) + (gptel--gh-validate-github-username github-username) + (if (= (length github-username) 0) + gptel-gh-github-token-file + (concat gptel-gh-github-token-file "_" github-username))) + +(defun gptel--gh-restore-github-token-from-file (github-username) + "Restore GitHub token from the file gptel-gh-github-token-file." + (gptel--gh-restore-from-file (gptel--gh-generate-github-token-filename github-username))) + +(defun gptel--gh-save-github-token-from-file (github-username token) + "Save GitHub token to the file gptel-gh-github-token-file." + (gptel--gh-save-to-file (gptel--gh-generate-github-token-filename github-username) token)) + +(defun gptel--gh-restore-from-file (file) "Restore saved object from FILE." (when (file-exists-p file) ;; We set the coding system to `utf-8-auto-dos' when reading so that @@ -194,7 +259,7 @@ (goto-char (point-min)) (read (current-buffer)))))) -(defun gptel--gh-save (file obj) +(defun gptel--gh-save-to-file (file obj) "Save OBJ to FILE." (let ((print-length nil) (print-level nil) @@ -203,7 +268,7 @@ (write-region (prin1-to-string obj) nil file nil :silent) obj)) -(defun gptel-gh-login () +(defun gptel-gh-login (github-username) "Login to GitHub Copilot API. This will prompt you to authorize in a browser and store the token. @@ -211,24 +276,15 @@ This will prompt you to authorize in a browser and store the token. In SSH sessions, the URL and code will be displayed for manual entry instead of attempting to open a browser automatically." (interactive) - ;; Determine which GitHub backend to use - (let ((gh-backend - (cond - ;; If current backend is GitHub, use it - ((and (boundp 'gptel-backend) - gptel-backend - (gptel--gh-p gptel-backend)) - gptel-backend) - ;; Otherwise, find any GitHub backend - ((cl-find-if (lambda (b) (gptel--gh-p b)) - (mapcar #'cdr gptel--known-backends))) - ;; No GitHub backend found - (t (user-error "No GitHub Copilot backend found. \ -Please set one up with `gptel-make-gh-copilot' first")))) + (message "Logging in using '%s'" github-username) + (let ((gh-backends (gptel--gh-get-backends-by-username github-username)) ;; Detect SSH sessions (in-ssh-session (or (getenv "SSH_CLIENT") (getenv "SSH_CONNECTION") (getenv "SSH_TTY")))) + ;; It shall only be possible to login when there exists a corresponding backend + (if (= (length gh-backends) 0) + (user-error "No GitHub CoPilot backend found for username '%s'" github-username)) (pcase-let (((map :device_code :user_code :verification_uri) (gptel--url-retrieve "https://github.com/login/device/code" @@ -252,26 +308,21 @@ Press ENTER to open GitHub in your browser. \ If your browser does not open automatically, browse to %s." user_code verification_uri)) (browse-url verification_uri) - (read-from-minibuffer "Press ENTER after authorizing. ")) - ;; Use gh-backend for token storage - (thread-last - (plist-get - (gptel--url-retrieve - "https://github.com/login/oauth/access_token" - :method 'post - :headers gptel--gh-auth-common-headers - :data `( :client_id ,gptel--gh-client-id - :device_code ,device_code - :grant_type "urn:ietf:params:oauth:grant-type:device_code")) - :access_token) - (gptel--gh-save gptel-gh-github-token-file) - (setf (gptel--gh-github-token gh-backend)))) - ;; Check gh-backend for success - (if (and (gptel--gh-github-token gh-backend) - (not (string-empty-p - (gptel--gh-github-token gh-backend)))) - (message "Successfully logged in to GitHub Copilot.") - (user-error "Error: You might not have access to GitHub Copilot Chat!")))) + (read-from-minibuffer "Press ENTER after authorizing.")) + (let ((github-token + (plist-get + (gptel--url-retrieve + "https://github.com/login/oauth/access_token" + :method 'post + :headers gptel--gh-auth-common-headers + :data `( :client_id ,gptel--gh-client-id + :device_code ,device_code + :grant_type "urn:ietf:params:oauth:grant-type:device_code")) + :access_token))) + (if (or (null github-token) (string-empty-p github-token)) + (user-error "Error: You might not have access to GitHub Copilot Chat!")) + (message "Successfully logged in to GitHub Copilot") + (gptel--gh-save-github-token github-username github-token))))) (defun gptel--gh-renew-token () "Renew session token." @@ -283,12 +334,8 @@ If your browser does not open automatically, browse to %s." . ,(format "token %s" (gptel--gh-github-token gptel-backend))) ,@gptel--gh-auth-common-headers)))) (if (not (plist-get token :token)) - (progn - (setf (gptel--gh-github-token gptel-backend) nil) - (user-error "Error: You might not have access to GitHub Copilot Chat!")) - (thread-last - (gptel--gh-save gptel-gh-token-file token) - (setf (gptel--gh-token gptel-backend)))))) + (user-error "Error: You might not have access to GitHub Copilot Chat!") + (setf (gptel--gh-token gptel-backend) token)))) (defun gptel--gh-auth () "Authenticate with GitHub Copilot API. @@ -296,32 +343,27 @@ If your browser does not open automatically, browse to %s." We first need github authorization (github token). Then we need a session token." (unless (gptel--gh-github-token gptel-backend) - (let ((token (gptel--gh-restore gptel-gh-github-token-file))) + (let* ((github-username (gptel--gh-github-username gptel-backend)) + (token (gptel--gh-load-github-token github-username))) (if token (setf (gptel--gh-github-token gptel-backend) token) - (gptel-gh-login)))) - - (when (null (gptel--gh-token gptel-backend)) - ;; try to load token from `gptel-gh-token-file' - (setf (gptel--gh-token gptel-backend) - (gptel--gh-restore gptel-gh-token-file))) + (gptel-gh-login github-username)))) (pcase-let (((map :token :expires_at) (gptel--gh-token gptel-backend))) (when (or (null token) (and expires_at - (> (round (float-time (current-time))) - expires_at))) + (> (round (float-time)) expires_at))) (gptel--gh-renew-token)))) ;;;###autoload (cl-defun gptel-make-gh-copilot - (name &key curl-args request-params + (name &key (github-username "") curl-args request-params (header (lambda () (gptel--gh-auth) `(("openai-intent" . "conversation-panel") ("authorization" . ,(concat "Bearer " - (plist-get (gptel--gh-token gptel-backend) :token))) + (plist-get (gptel--gh-token gptel-backend) :token))) ("x-request-id" . ,(gptel--gh-uuid)) ("vscode-sessionid" . ,(or (gptel--gh-sessionid gptel-backend) "")) ("vscode-machineid" . ,(or (gptel--gh-machineid gptel-backend) "")) @@ -338,6 +380,9 @@ Then we need a session token." Keyword arguments: +GITHUB-USERNAME (optional) is an indicator of which GitHub account to associate +the backend with. This enables backends to be logged in as a separate user. + CURL-ARGS (optional) is a list of additional Curl arguments. HOST (optional) is the API host, typically \"api.githubcopilot.com\". @@ -385,6 +430,7 @@ parameters (as plist keys) and values supported by the API. Use these to set parameters that gptel does not provide user options for." (declare (indent 1)) + (gptel--gh-validate-github-username github-username) (let ((backend (gptel--make-gh :name name :host host @@ -395,6 +441,7 @@ for." :stream stream :request-params request-params :curl-args curl-args + :github-username github-username :url (concat protocol "://" host endpoint) :machineid (gptel--gh-machine-id)))) (setf (alist-get name gptel--known-backends nil nil #'equal) backend)