Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
901b56c
Add background key rotation via LaunchAgent
shawntz Dec 10, 2025
7e077a5
Update postinstall
shawntz Dec 13, 2025
7243e97
Initial plan
Copilot Dec 13, 2025
c127680
Initial plan
Copilot Dec 13, 2025
481f3e1
Initial plan
Copilot Dec 13, 2025
db4be56
Initial plan
Copilot Dec 13, 2025
a57dde0
Initial plan
Copilot Dec 13, 2025
24d622a
Initial plan
Copilot Dec 13, 2025
5bf9612
Initial plan
Copilot Dec 13, 2025
e9cc706
Initial plan
Copilot Dec 13, 2025
42c08e7
Initial plan
Copilot Dec 13, 2025
8f1554c
Initial plan
Copilot Dec 13, 2025
ae432d3
Change log location from /tmp to ~/Library/Logs/cassh/
Copilot Dec 13, 2025
1339a33
Fix: Use tilde (~) instead of $HOME in plist paths
Copilot Dec 13, 2025
f654518
Fix findGitHubKeyIDByTitle to use exact title matching
Copilot Dec 13, 2025
efe8313
Add error handling to postinstall script for LaunchAgent operations
Copilot Dec 13, 2025
03e71f5
Add fallback logic for legacy key rotation
Copilot Dec 13, 2025
1cb0233
Remove contradictory LaunchOnlyOnce key from rotate plist
Copilot Dec 13, 2025
a3aba0f
Add security and error handling for log directory creation
Copilot Dec 13, 2025
a172fba
Improve error handling with conditional check for successful copy
Copilot Dec 13, 2025
549c643
Remove RunAtLoad from rotation LaunchAgent to prevent immediate execu…
Copilot Dec 13, 2025
2498576
Add username validation to prevent potential command injection
Copilot Dec 13, 2025
908ab15
Use getLegacyKeyTitle for backward compatibility in key lookup
Copilot Dec 13, 2025
65a437e
Fix title matching to use exact field match instead of substring search
Copilot Dec 13, 2025
2073881
Add explicit empty line check and clarify field count requirement
Copilot Dec 13, 2025
a736de9
Optimize key lookup to use single pass with exact matching
Copilot Dec 13, 2025
c054471
Address code review feedback: improve validation and reduce duplication
Copilot Dec 13, 2025
5c65e05
Add proper validation for connection ID and split parts
Copilot Dec 13, 2025
f287946
Fix SSH key title matching to use exact match instead of substring
Copilot Dec 13, 2025
481fdb0
Refactor: Extract duplicated LaunchAgent unloading logic into function
Copilot Dec 13, 2025
7c69d6c
Improve validation and add clarifying comments
Copilot Dec 13, 2025
464cabb
Update key refresh to trigger UI status update
shawntz Dec 19, 2025
bc06aac
Merge branch 'feat/add-headless-launchagent' of https://github.com/sh…
shawntz Dec 19, 2025
a45e52f
Merge pull request #17 from shawntz/copilot/sub-pr-7-again
shawntz Dec 19, 2025
dd4fae1
Merge pull request #26 from shawntz/copilot/sub-pr-7-e1862c20-e1a7-46…
shawntz Jan 11, 2026
5eb1547
Update cmd/cassh-menubar/main.go
shawntz Jan 11, 2026
1ed17cd
Update cmd/cassh-menubar/main.go
shawntz Jan 11, 2026
fe2e9ef
Merge pull request #25 from shawntz/copilot/sub-pr-7-07734849-0bdc-4d…
shawntz Jan 11, 2026
a858119
Merge pull request #24 from shawntz/copilot/sub-pr-7-b0705b1b-7749-47…
shawntz Jan 11, 2026
c557362
Merge branch 'feat/add-headless-launchagent' into copilot/sub-pr-7-c9…
shawntz Jan 11, 2026
1497242
Merge pull request #23 from shawntz/copilot/sub-pr-7-c919a236-4d18-46…
shawntz Jan 11, 2026
a7b27dd
Update packaging/macos/scripts/postinstall
shawntz Jan 11, 2026
88b5b42
Update packaging/macos/scripts/postinstall
shawntz Jan 11, 2026
82a269f
Update packaging/macos/scripts/postinstall
shawntz Jan 11, 2026
2b37caa
Update packaging/macos/scripts/postinstall
shawntz Jan 11, 2026
ffa6a85
Merge branch 'feat/add-headless-launchagent' into copilot/sub-pr-7-81…
shawntz Jan 11, 2026
fc9c04e
Merge pull request #22 from shawntz/copilot/sub-pr-7-81d43b7f-7f61-4c…
shawntz Jan 11, 2026
de7b7d6
Merge branch 'feat/add-headless-launchagent' into copilot/sub-pr-7-pl…
shawntz Jan 11, 2026
00d25c6
Merge pull request #21 from shawntz/copilot/sub-pr-7-please-work
shawntz Jan 11, 2026
1d3b22e
Update cmd/cassh-menubar/main.go
shawntz Jan 11, 2026
8c2e154
Merge branch 'feat/add-headless-launchagent' into copilot/sub-pr-7-on…
shawntz Jan 11, 2026
e67b4c0
Merge pull request #20 from shawntz/copilot/sub-pr-7-one-more-time
shawntz Jan 11, 2026
fd6e0c1
Merge pull request #19 from shawntz/copilot/sub-pr-7-yet-again
shawntz Jan 11, 2026
48c6da8
Merge branch 'feat/add-headless-launchagent' into copilot/sub-pr-7-an…
shawntz Jan 11, 2026
5db9d0f
Merge pull request #18 from shawntz/copilot/sub-pr-7-another-one
shawntz Jan 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
216 changes: 190 additions & 26 deletions cmd/cassh-menubar/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,19 @@ type ConnectionStatus struct {

func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile)

// Check for headless mode flags
if len(os.Args) > 1 {
switch os.Args[1] {
case "--rotate-keys":
runHeadlessKeyRotation()
return
case "--version":
fmt.Printf("cassh %s (commit %s)\n", version, buildCommit)
return
}
}

log.Println("Starting cassh-menubar...")

// Initialize native notifications (request permission)
Expand Down Expand Up @@ -397,10 +410,19 @@ func handleConnectionAction(connID string) {
return
}

// Find the connection index for UI updates
connIdx := -1
for i, c := range cfg.User.Connections {
if c.ID == connID {
connIdx = i
break
}
}

if conn.Type == config.ConnectionTypeEnterprise {
generateCertForConnection(conn)
} else {
refreshKeyForConnection(conn)
refreshKeyForConnection(conn, connIdx)
}
}

Expand Down Expand Up @@ -478,7 +500,7 @@ func generateCertForConnection(conn *config.Connection) {
}

// refreshKeyForConnection handles key refresh for personal GitHub connection
func refreshKeyForConnection(conn *config.Connection) {
func refreshKeyForConnection(conn *config.Connection, connIdx int) {
// Check if gh CLI is authenticated
ghStatus := checkGHAuth()
if !ghStatus.Installed {
Expand All @@ -494,6 +516,9 @@ func refreshKeyForConnection(conn *config.Connection) {
if err := rotatePersonalGitHubSSH(conn); err != nil {
log.Printf("Failed to rotate key: %v", err)
sendNotification("cassh", fmt.Sprintf("Failed to rotate key: %v", err), false)
if connIdx >= 0 {
updateConnectionStatus(connIdx)
}
return
}

Expand All @@ -502,6 +527,11 @@ func refreshKeyForConnection(conn *config.Connection) {
log.Printf("Failed to save config after key rotation: %v", err)
}

// Update UI immediately
if connIdx >= 0 {
updateConnectionStatus(connIdx)
}

sendNotification("cassh", fmt.Sprintf("SSH key rotated for %s", conn.Name), false)
log.Printf("Rotated SSH key for: %s", conn.Name)
}
Expand Down Expand Up @@ -1182,7 +1212,6 @@ func formatDuration(d time.Duration) string {
return fmt.Sprintf("%d minutes", minutes)
}


// uninstallCassh removes cassh and all its data from the system
func uninstallCassh() {
// Show confirmation dialog
Expand Down Expand Up @@ -1214,16 +1243,21 @@ func uninstallCassh() {
removeGitConfigForConnection(&conn)
}

// 2. Unregister from login items (SMAppService) and remove LaunchAgent
// 2. Unregister from login items (SMAppService) and remove LaunchAgents
unregisterAsLoginItem()
// Remove user-level LaunchAgent
// Remove user-level LaunchAgents
userLaunchAgentPath := filepath.Join(homeDir, "Library", "LaunchAgents", "com.shawnschwartz.cassh.plist")
exec.Command("launchctl", "unload", userLaunchAgentPath).Run()
os.Remove(userLaunchAgentPath)
// Remove system-level LaunchAgent (installed by PKG)
userRotateAgentPath := filepath.Join(homeDir, "Library", "LaunchAgents", "com.shawnschwartz.cassh.rotate.plist")
exec.Command("launchctl", "unload", userRotateAgentPath).Run()
os.Remove(userRotateAgentPath)
// Remove system-level LaunchAgents (installed by PKG)
systemLaunchAgentPath := "/Library/LaunchAgents/com.shawnschwartz.cassh.plist"
exec.Command("launchctl", "unload", systemLaunchAgentPath).Run()
// System LaunchAgent requires admin to remove - will be handled by the uninstall script
systemRotateAgentPath := "/Library/LaunchAgents/com.shawnschwartz.cassh.rotate.plist"
exec.Command("launchctl", "unload", systemRotateAgentPath).Run()
// System LaunchAgents require admin to remove - will be handled by the uninstall script

// 3. Remove Application Support directory (contains user config)
appSupportDir := filepath.Join(homeDir, "Library", "Application Support", "cassh")
Expand Down Expand Up @@ -1264,7 +1298,7 @@ LAUNCH_AGENT='/Library/LaunchAgents/com.shawnschwartz.cassh.plist'

# Create AppleScript to run with admin privileges
cat > /tmp/cassh_uninstall.scpt << 'APPLESCRIPT'
do shell script "rm -rf '/Applications/cassh.app' '/Library/LaunchAgents/com.shawnschwartz.cassh.plist' 2>/dev/null || true" with prompt "cassh needs to remove the application." with administrator privileges
do shell script "rm -rf '/Applications/cassh.app' '/Library/LaunchAgents/com.shawnschwartz.cassh.plist' '/Library/LaunchAgents/com.shawnschwartz.cassh.rotate.plist' 2>/dev/null || true" with prompt "cassh needs to remove the application." with administrator privileges
APPLESCRIPT

if osascript /tmp/cassh_uninstall.scpt 2>/dev/null; then
Expand All @@ -1279,12 +1313,13 @@ rm -f /tmp/cassh_uninstall.scpt
rm -f "$0"
`, appPath)
} else if appPath != "" {
// Can delete app without admin privileges, but still try to remove system LaunchAgent
// Can delete app without admin privileges, but still try to remove system LaunchAgents
uninstallScript = fmt.Sprintf(`#!/bin/bash
sleep 2
rm -rf '%s'
# Try to remove system LaunchAgent (may fail without admin)
# Try to remove system LaunchAgents (may fail without admin)
rm -f /Library/LaunchAgents/com.shawnschwartz.cassh.plist 2>/dev/null || true
rm -f /Library/LaunchAgents/com.shawnschwartz.cassh.rotate.plist 2>/dev/null || true
osascript -e 'display notification "cassh has been uninstalled" with title "Uninstall Complete"'
rm -f "$0"
`, appPath)
Expand Down Expand Up @@ -2014,8 +2049,8 @@ func findGHBinary() string {

// Check common Homebrew locations
commonPaths := []string{
"/opt/homebrew/bin/gh", // Apple Silicon
"/usr/local/bin/gh", // Intel Mac
"/opt/homebrew/bin/gh", // Apple Silicon
"/usr/local/bin/gh", // Intel Mac
"/home/linuxbrew/.linuxbrew/bin/gh", // Linux Homebrew
}

Expand Down Expand Up @@ -2113,6 +2148,28 @@ func generateSSHKeyForPersonal(conn *config.Connection) error {
return nil
}

// getKeyTitle generates a unique SSH key title for GitHub
// Format: cassh-{connID}@{hostname} (e.g., cassh-personal-123@MacBook-Pro)
func getKeyTitle(connID string) string {
hostname, err := os.Hostname()
if err != nil || hostname == "" {
hostname = "unknown"
}
// Clean hostname - replace dots with dashes for cleaner display
hostname = strings.ReplaceAll(hostname, ".", "-")
// Truncate long hostnames
if len(hostname) > 30 {
hostname = hostname[:30]
}
return fmt.Sprintf("cassh-%s@%s", connID, hostname)
}

// getLegacyKeyTitle returns the old key title format without hostname
// Used for backwards compatibility when looking up existing keys
func getLegacyKeyTitle(connID string) string {
return fmt.Sprintf("cassh-%s", connID)
}
Comment on lines +2167 to +2171
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The getLegacyKeyTitle function is defined but never used in the codebase. If this is intended for backward compatibility with existing keys that were created with the old title format (without hostname), it should be called somewhere in the key rotation or lookup logic. Otherwise, when users upgrade, the system won't be able to find and delete their old keys from GitHub that were created with the legacy format. Consider either using this function in findGitHubKeyIDByTitle as a fallback, or removing it if not needed.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

Comment on lines +2167 to +2171
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The getLegacyKeyTitle function is defined but never used. Since the key title format has changed from "cassh-{connID}" to "cassh-{connID}@{hostname}", existing keys with the old format will not be found by findGitHubKeyIDByTitle when rotating keys. This means the old key will not be deleted from GitHub during rotation, leaving orphaned keys. Consider using getLegacyKeyTitle as a fallback in rotatePersonalGitHubSSH when conn.GitHubKeyID is empty, to find and delete keys created with the old format.

Copilot uses AI. Check for mistakes.

Copy link

Copilot AI Dec 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The getLegacyKeyTitle function is defined but never used. For keys created before this change (using the old title format "cassh-{connID}"), the rotation logic in rotatePersonalGitHubSSH will fail to delete the old key from GitHub if conn.GitHubKeyID is empty. Consider adding a fallback in rotatePersonalGitHubSSH to look up the key by legacy title when GitHubKeyID is empty, or remove this function if it's not needed.

Suggested change
// rotatePersonalGitHubSSH rotates the personal GitHub SSH key, deleting the old key if present
func rotatePersonalGitHubSSH(conn *config.Connection, keyPath string) error {
// Delete old key if we have the key ID
keyID := conn.GitHubKeyID
if keyID == "" {
// Try to find by current title
keyID = findGitHubKeyIDByTitle(getKeyTitle(conn.ID))
if keyID == "" {
// Try to find by legacy title
keyID = findGitHubKeyIDByTitle(getLegacyKeyTitle(conn.ID))
}
}
if keyID != "" {
err := deleteGitHubKeyByID(keyID)
if err != nil {
log.Printf("Failed to delete old GitHub SSH key (ID: %s): %v", keyID, err)
} else {
log.Printf("Deleted old GitHub SSH key (ID: %s)", keyID)
}
}
// Upload new key
title := getKeyTitle(conn.ID)
newKeyID, err := uploadSSHKeyToGitHub(keyPath, title)
if err != nil {
return fmt.Errorf("failed to upload new SSH key: %w", err)
}
conn.GitHubKeyID = newKeyID
return nil
}

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot please open a new pull request to apply changes based on this feedback

Comment on lines +2167 to +2172
Copy link

Copilot AI Jan 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The getLegacyKeyTitle function is defined but never used in the codebase. If backward compatibility with old key titles is needed during rotation (to find and delete old keys created before this PR), this function should be called in the rotatePersonalGitHubSSH or deleteSSHKeyFromGitHub functions. Otherwise, this function should be removed as dead code.

Suggested change
// getLegacyKeyTitle returns the old key title format without hostname
// Used for backwards compatibility when looking up existing keys
func getLegacyKeyTitle(connID string) string {
return fmt.Sprintf("cassh-%s", connID)
}

Copilot uses AI. Check for mistakes.
// uploadSSHKeyToGitHub uploads an SSH public key to GitHub using gh CLI
// Returns the GitHub key ID for later deletion
func uploadSSHKeyToGitHub(keyPath string, title string) (string, error) {
Expand Down Expand Up @@ -2145,6 +2202,7 @@ func uploadSSHKeyToGitHub(keyPath string, title string) (string, error) {
}

// findGitHubKeyIDByTitle finds the GitHub SSH key ID by its title
// Also checks legacy title format for backward compatibility
func findGitHubKeyIDByTitle(title string) string {
cmd := exec.Command(findGHBinary(), "ssh-key", "list")
output, err := cmd.Output()
Expand All @@ -2153,18 +2211,41 @@ func findGitHubKeyIDByTitle(title string) string {
return ""
}

// Prepare legacy title if applicable
// Check for new format (cassh-{connID}@{hostname}) vs legacy (cassh-{connID})
var legacyTitle string
if strings.Contains(title, "@") && strings.HasPrefix(title, "cassh-") {
// Extract connection ID from new title format (cassh-{connID}@{hostname})
withoutPrefix := strings.TrimPrefix(title, "cassh-")
parts := strings.Split(withoutPrefix, "@")
// Validate both connection ID and hostname are present
if len(parts) >= 2 && parts[0] != "" && parts[1] != "" {
connID := parts[0]
legacyTitle = getLegacyKeyTitle(connID)
}
}

// Parse output to find our key
// Format: "TITLE TYPE ADDED KEY_ID"
// Format: "TITLE TYPE KEY ADDED KEY_ID KEY_TYPE"
// Example: "cassh-personal-123 ssh-ed25519 AAAA... 2025-12-09T02:43:40Z 137889594 authentication"
lines := strings.Split(string(output), "\n")
for _, line := range lines {
if strings.Contains(line, title) {
fields := strings.Fields(line)
if len(fields) >= 4 {
// Key ID is typically the last field
return fields[len(fields)-1]
fields := strings.Fields(line)
if len(fields) >= 5 {
keyTitle := fields[0]
keyID := fields[len(fields)-2] // Key ID is the second-to-last field
// Check for exact match with new title format
if keyTitle == title {
return keyID
}
// Check for exact match with legacy title format (if applicable)
if legacyTitle != "" && keyTitle == legacyTitle {
log.Printf("Found key with legacy title format: %s", legacyTitle)
return keyID
}
}
Comment on lines +2233 to 2246
Copy link

Copilot AI Jan 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The key title parsing logic assumes exactly 6 fields in the output, with the title being everything before the last 5 fields. However, if the gh CLI output format changes in future versions, or if there are edge cases with fewer fields, this could fail silently. Consider adding validation or logging when the field count is unexpected, and handling the case where len(fields) is between 1 and 5 more explicitly.

Suggested change
fields := strings.Fields(line)
if len(fields) >= 5 {
keyTitle := fields[0]
keyID := fields[len(fields)-2] // Key ID is the second-to-last field
// Check for exact match with new title format
if keyTitle == title {
return keyID
}
// Check for exact match with legacy title format (if applicable)
if legacyTitle != "" && keyTitle == legacyTitle {
log.Printf("Found key with legacy title format: %s", legacyTitle)
return keyID
}
}
line = strings.TrimSpace(line)
if line == "" {
continue
}
fields := strings.Fields(line)
// We expect at least 6 fields:
// TITLE (may contain spaces) + TYPE + KEY + ADDED + KEY_ID + KEY_TYPE
if len(fields) < 2 {
log.Printf("Unexpected ssh-key list line (too few fields): %q", line)
continue
}
if len(fields) < 6 {
log.Printf("Unexpected ssh-key list field count (%d) for line: %q", len(fields), line)
continue
}
// Title is everything before the last 5 fields
titleFieldCount := len(fields) - 5
keyTitle := strings.Join(fields[:titleFieldCount], " ")
keyID := fields[len(fields)-2] // Key ID is the second-to-last field
// Check for exact match with new title format
if keyTitle == title {
return keyID
}
// Check for exact match with legacy title format (if applicable)
if legacyTitle != "" && keyTitle == legacyTitle {
log.Printf("Found key with legacy title format: %s", legacyTitle)
return keyID
}

Copilot uses AI. Check for mistakes.
}

return ""
}

Expand Down Expand Up @@ -2198,7 +2279,7 @@ func setupPersonalGitHubSSH(conn *config.Connection) error {
}

// 2. Upload to GitHub
keyTitle := fmt.Sprintf("cassh-%s", conn.ID)
keyTitle := getKeyTitle(conn.ID)
keyID, err := uploadSSHKeyToGitHub(conn.SSHKeyPath, keyTitle)
if err != nil {
return fmt.Errorf("key upload failed: %w", err)
Expand All @@ -2217,10 +2298,31 @@ func rotatePersonalGitHubSSH(conn *config.Connection) error {
log.Printf("Rotating SSH key for %s", conn.Name)

// 1. Delete old key from GitHub
if conn.GitHubKeyID != "" {
if err := deleteSSHKeyFromGitHub(conn.GitHubKeyID); err != nil {
log.Printf("Warning: failed to delete old key: %v", err)
keyID := conn.GitHubKeyID
if keyID == "" {
// Try to find by current title
keyID = findGitHubKeyIDByTitle(getKeyTitle(conn.ID))
if keyID == "" {
// Try to find by legacy title
keyID = findGitHubKeyIDByTitle(getLegacyKeyTitle(conn.ID))
}
}
if keyID != "" {
if err := deleteSSHKeyFromGitHub(keyID); err != nil {
log.Printf("Warning: failed to delete old key (ID: %s): %v", keyID, err)
Copy link

Copilot AI Jan 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The key rotation logic changes the key title format from 'cassh-{connID}' to 'cassh-{connID}@{hostname}', but there's no migration path for existing keys. When rotatePersonalGitHubSSH runs, it attempts to delete the old key using conn.GitHubKeyID, but if that fails or is empty, old keys with the legacy title format will remain orphaned on GitHub. Consider attempting to find and delete legacy-format keys by calling findGitHubKeyIDByTitle with both the new and legacy title formats.

Suggested change
log.Printf("Warning: failed to delete old key (ID: %s): %v", keyID, err)
log.Printf("Warning: failed to delete old key (ID: %s): %v", keyID, err)
// Attempt migration cleanup: look for keys by both new and legacy titles
if conn.ID != "" {
currentTitle := getKeyTitle(conn.ID)
if currentTitle != "" {
if altID := findGitHubKeyIDByTitle(currentTitle); altID != "" && altID != keyID {
if err := deleteSSHKeyFromGitHub(altID); err != nil {
log.Printf("Warning: failed to delete key found by current title (ID: %s): %v", altID, err)
} else {
log.Printf("Deleted GitHub SSH key found by current title (ID: %s)", altID)
}
}
}
legacyTitle := getLegacyKeyTitle(conn.ID)
if legacyTitle != "" {
if altID := findGitHubKeyIDByTitle(legacyTitle); altID != "" && altID != keyID {
if err := deleteSSHKeyFromGitHub(altID); err != nil {
log.Printf("Warning: failed to delete key found by legacy title (ID: %s): %v", altID, err)
} else {
log.Printf("Deleted GitHub SSH key found by legacy title (ID: %s)", altID)
}
}
}
}

Copilot uses AI. Check for mistakes.
// Continue anyway - we still want to generate a new key
} else {
log.Printf("Deleted old GitHub SSH key (ID: %s)", keyID)
}
} else if conn.ID != "" {
// No stored key ID - try to find and delete using both new and legacy title formats
// This handles migration from older versions where GitHubKeyID wasn't tracked
keyTitle := getKeyTitle(conn.ID)
if keyID := findGitHubKeyIDByTitle(keyTitle); keyID != "" {
log.Printf("Found existing key on GitHub during rotation (ID: %s)", keyID)
if err := deleteSSHKeyFromGitHub(keyID); err != nil {
log.Printf("Warning: failed to delete existing key: %v", err)
}
}
}

Expand All @@ -2234,17 +2336,17 @@ func rotatePersonalGitHubSSH(conn *config.Connection) error {
}

// 4. Upload new key to GitHub
keyTitle := fmt.Sprintf("cassh-%s", conn.ID)
keyID, err := uploadSSHKeyToGitHub(conn.SSHKeyPath, keyTitle)
keyTitle := getKeyTitle(conn.ID)
newKeyID, err := uploadSSHKeyToGitHub(conn.SSHKeyPath, keyTitle)
if err != nil {
return fmt.Errorf("key upload failed: %w", err)
}

// 5. Update connection metadata
conn.GitHubKeyID = keyID
conn.GitHubKeyID = newKeyID
conn.KeyCreatedAt = time.Now().Unix()

log.Printf("SSH key rotated for %s (new key ID: %s)", conn.Name, keyID)
log.Printf("SSH key rotated for %s (new key ID: %s)", conn.Name, newKeyID)
return nil
}

Expand Down Expand Up @@ -2311,6 +2413,68 @@ func checkAndRotateExpiredKeys() {
}
}

// runHeadlessKeyRotation runs key rotation without starting the GUI
// This is called when the app is launched with --rotate-keys flag
// Used by the LaunchAgent for background key rotation
func runHeadlessKeyRotation() {
log.Println("Running headless key rotation...")

// Load config
userCfg, err := config.LoadUserConfig()
if err != nil {
log.Printf("Could not load user config: %v", err)
os.Exit(1)
}

if len(userCfg.Connections) == 0 {
log.Println("No connections configured, nothing to rotate")
os.Exit(0)
}

// Check if gh CLI is available
ghStatus := checkGHAuth()
if !ghStatus.Installed {
log.Println("GitHub CLI (gh) not installed, cannot rotate keys")
os.Exit(1)
}
if !ghStatus.Authenticated {
log.Println("GitHub CLI not authenticated, cannot rotate keys")
os.Exit(1)
}

rotatedCount := 0
for i := range userCfg.Connections {
conn := &userCfg.Connections[i]
if needsKeyRotation(conn) {
log.Printf("Key rotation needed for %s (age: %v, policy: %dh)",
conn.Name,
time.Since(time.Unix(conn.KeyCreatedAt, 0)).Round(time.Hour),
conn.KeyRotationHours)

if err := rotatePersonalGitHubSSH(conn); err != nil {
log.Printf("Failed to rotate key for %s: %v", conn.Name, err)
continue
}

rotatedCount++
log.Printf("Successfully rotated key for %s", conn.Name)
}
}

// Save config if any keys were rotated
if rotatedCount > 0 {
if err := config.SaveUserConfig(userCfg); err != nil {
log.Printf("Failed to save config: %v", err)
os.Exit(1)
}
log.Printf("Rotated %d key(s)", rotatedCount)
} else {
log.Println("No keys needed rotation")
}

os.Exit(0)
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function exits with code 0 even when some key rotations fail. If rotation fails for one or more connections (line 2412 continues on error), but succeeds for others, the function still exits with 0 indicating success. This could mask partial failures in monitoring systems. Consider tracking failed rotations and exiting with a non-zero code if any rotations failed, or provide more nuanced exit codes (e.g., 0 for full success, 1 for partial failure, 2 for complete failure).

Copilot uses AI. Check for mistakes.
}
Comment on lines +2464 to +2476
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The runHeadlessKeyRotation function modifies user configuration by calling config.SaveUserConfig without any locking mechanism. If the GUI app is running simultaneously and also modifying the configuration (e.g., during user-initiated changes), there could be a race condition leading to lost updates. Consider implementing file locking or using atomic file operations to ensure concurrent modifications don't corrupt the configuration.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback


// scheduleRestart restarts the app to rebuild the menu after adding connections
func scheduleRestart() {
// Wait for the HTTP response to be sent
Expand Down
36 changes: 36 additions & 0 deletions packaging/macos/com.shawnschwartz.cassh.rotate.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.shawnschwartz.cassh.rotate</string>

<key>ProgramArguments</key>
<array>
<string>/Applications/cassh.app/Contents/MacOS/cassh</string>
<string>--rotate-keys</string>
</array>

<!-- Run every hour (no immediate execution on load) -->
<key>StartInterval</key>
<integer>3600</integer>
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment states "no immediate execution on load" but StartInterval alone doesn't guarantee this behavior. By default, without an explicit RunAtLoad key set to false, launchd may run the job immediately when loaded. If you want to ensure the job only runs on the interval schedule and not immediately on load, add "<key>RunAtLoad</key><false/>" after the StartInterval key. Otherwise, update the comment to reflect the actual behavior.

Suggested change
<integer>3600</integer>
<integer>3600</integer>
<key>RunAtLoad</key>
<false/>

Copilot uses AI. Check for mistakes.

Copy link

Copilot AI Jan 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment on line 14 states 'Run every hour (no immediate execution on load)', but StartInterval actually triggers immediately on load and then every hour after that according to macOS LaunchAgent documentation. If you truly want to prevent immediate execution on load, you should use StartCalendarInterval instead of StartInterval, or add RunAtLoad set to false explicitly.

Suggested change
<!-- Do not run immediately when the job is loaded -->
<key>RunAtLoad</key>
<false/>

Copilot uses AI. Check for mistakes.
<!-- Also run shortly after load (30 seconds delay) -->
<key>RunAtLoad</key>
<true/>

<!-- Low priority background task -->
<key>ProcessType</key>
<string>Background</string>

<!-- Nice value for low CPU priority -->
<key>Nice</key>
<integer>10</integer>

<!-- Logging -->
<key>StandardOutPath</key>
<string>~/Library/Logs/cassh/cassh-rotate.log</string>
<key>StandardErrorPath</key>
<string>~/Library/Logs/cassh/cassh-rotate.log</string>
Comment on lines +30 to +34
Copy link

Copilot AI Jan 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logging paths use world-readable /tmp directory which could expose sensitive information. SSH key rotation logs may contain usernames, connection names, or error messages with sensitive details. Consider using a user-specific log directory like ~/Library/Logs/cassh-rotate.log or /var/log/cassh-rotate.log with appropriate permissions.

Copilot uses AI. Check for mistakes.
</dict>
</plist>
Loading