detached 61c0290

fix(auth): gordon auth internal reads stale creds when XDG_RUNTIME_DIR differs between daemon and CLI

commit
61c0290515735e59fe179c4d9fe50775b44b3550
author
bnema · · 2026-02-24 21:47 UTC
parent
095150c
signature
unsigned
2 files · +132 -11
M internal/app/run.go +51 -11
@@ -819,19 +819,59 @@ func cleanupInternalCredentials() {
819819 _ = os.Remove(getInternalCredentialsFile())
820820 }
821821
822-// GetInternalCredentials reads the internal registry credentials from file.
823-// This is used by the CLI to display credentials for manual recovery.
824-func GetInternalCredentials() (*InternalCredentials, error) {
825- credFile := getInternalCredentialsFile()
826- data, err := os.ReadFile(credFile)
827- if err != nil {
828- return nil, fmt.Errorf("failed to read credentials file (is Gordon running?): %w", err)
822+// getInternalCredentialsCandidates returns candidate file paths in priority order:
823+// 1. XDG_RUNTIME_DIR/gordon/ (set by systemd for the daemon)
824+// 2. /run/user/<uid>/gordon/ (well-known systemd default, for CLI in shells without XDG_RUNTIME_DIR)
825+// 3. ~/.gordon/run/ (fallback for non-systemd environments)
826+func getInternalCredentialsCandidates() []string {
827+ var candidates []string
828+
829+ // 1. XDG_RUNTIME_DIR (set in daemon's environment)
830+ if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" {
831+ candidates = append(candidates, filepath.Join(runtimeDir, "gordon", "internal-creds.json"))
832+ }
833+
834+ // 2. /run/user/<uid>/gordon/ (systemd default, may not be in CLI's env)
835+ uid := os.Getuid()
836+ sysRuntime := filepath.Join("/run/user", fmt.Sprintf("%d", uid), "gordon", "internal-creds.json")
837+ // Avoid duplicate if XDG_RUNTIME_DIR already points here
838+ if len(candidates) == 0 || candidates[0] != sysRuntime {
839+ candidates = append(candidates, sysRuntime)
829840 }
830- var creds InternalCredentials
831- if err := json.Unmarshal(data, &creds); err != nil {
832- return nil, fmt.Errorf("failed to parse credentials: %w", err)
841+
842+ // 3. ~/.gordon/run/ fallback
843+ if homeDir, err := os.UserHomeDir(); err == nil {
844+ candidates = append(candidates, filepath.Join(homeDir, ".gordon", "run", "internal-creds.json"))
833845 }
834- return &creds, nil
846+
847+ return candidates
848+}
849+
850+// GetInternalCredentialsFromCandidates reads credentials from the first candidate file that exists.
851+// Exported for testing.
852+func GetInternalCredentialsFromCandidates(candidates []string) (*InternalCredentials, error) {
853+ for _, path := range candidates {
854+ data, err := os.ReadFile(path)
855+ if os.IsNotExist(err) {
856+ continue
857+ }
858+ if err != nil {
859+ return nil, fmt.Errorf("failed to read credentials file %s: %w", path, err)
860+ }
861+ var creds InternalCredentials
862+ if err := json.Unmarshal(data, &creds); err != nil {
863+ return nil, fmt.Errorf("failed to parse credentials at %s: %w", path, err)
864+ }
865+ return &creds, nil
866+ }
867+ return nil, fmt.Errorf("no credentials file found (is Gordon running?): checked %v", candidates)
868+}
869+
870+// GetInternalCredentials reads the internal registry credentials from file.
871+// Probes all candidate runtime directories so CLI works regardless of whether
872+// XDG_RUNTIME_DIR is set in the current shell environment.
873+func GetInternalCredentials() (*InternalCredentials, error) {
874+ return GetInternalCredentialsFromCandidates(getInternalCredentialsCandidates())
835875 }
836876
837877 // createAuthService creates the authentication service and token store.
@@ -819,19 +819,59 @@ func cleanupInternalCredentials() {
819 _ = os.Remove(getInternalCredentialsFile())819 _ = os.Remove(getInternalCredentialsFile())
820 }820 }
821 821
822-// GetInternalCredentials reads the internal registry credentials from file.822+// getInternalCredentialsCandidates returns candidate file paths in priority order:
823-// This is used by the CLI to display credentials for manual recovery.823+// 1. XDG_RUNTIME_DIR/gordon/ (set by systemd for the daemon)
824-func GetInternalCredentials() (*InternalCredentials, error) {824+// 2. /run/user/<uid>/gordon/ (well-known systemd default, for CLI in shells without XDG_RUNTIME_DIR)
825- credFile := getInternalCredentialsFile()825+// 3. ~/.gordon/run/ (fallback for non-systemd environments)
826- data, err := os.ReadFile(credFile)826+func getInternalCredentialsCandidates() []string {
827- if err != nil {827+ var candidates []string
828- return nil, fmt.Errorf("failed to read credentials file (is Gordon running?): %w", err)828+
829+ // 1. XDG_RUNTIME_DIR (set in daemon's environment)
830+ if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" {
831+ candidates = append(candidates, filepath.Join(runtimeDir, "gordon", "internal-creds.json"))
832+ }
833+
834+ // 2. /run/user/<uid>/gordon/ (systemd default, may not be in CLI's env)
835+ uid := os.Getuid()
836+ sysRuntime := filepath.Join("/run/user", fmt.Sprintf("%d", uid), "gordon", "internal-creds.json")
837+ // Avoid duplicate if XDG_RUNTIME_DIR already points here
838+ if len(candidates) == 0 || candidates[0] != sysRuntime {
839+ candidates = append(candidates, sysRuntime)
829 }840 }
830- var creds InternalCredentials841+
831- if err := json.Unmarshal(data, &creds); err != nil {842+ // 3. ~/.gordon/run/ fallback
832- return nil, fmt.Errorf("failed to parse credentials: %w", err)843+ if homeDir, err := os.UserHomeDir(); err == nil {
844+ candidates = append(candidates, filepath.Join(homeDir, ".gordon", "run", "internal-creds.json"))
833 }845 }
834- return &creds, nil846+
847+ return candidates
848+}
849+
850+// GetInternalCredentialsFromCandidates reads credentials from the first candidate file that exists.
851+// Exported for testing.
852+func GetInternalCredentialsFromCandidates(candidates []string) (*InternalCredentials, error) {
853+ for _, path := range candidates {
854+ data, err := os.ReadFile(path)
855+ if os.IsNotExist(err) {
856+ continue
857+ }
858+ if err != nil {
859+ return nil, fmt.Errorf("failed to read credentials file %s: %w", path, err)
860+ }
861+ var creds InternalCredentials
862+ if err := json.Unmarshal(data, &creds); err != nil {
863+ return nil, fmt.Errorf("failed to parse credentials at %s: %w", path, err)
864+ }
865+ return &creds, nil
866+ }
867+ return nil, fmt.Errorf("no credentials file found (is Gordon running?): checked %v", candidates)
868+}
869+
870+// GetInternalCredentials reads the internal registry credentials from file.
871+// Probes all candidate runtime directories so CLI works regardless of whether
872+// XDG_RUNTIME_DIR is set in the current shell environment.
873+func GetInternalCredentials() (*InternalCredentials, error) {
874+ return GetInternalCredentialsFromCandidates(getInternalCredentialsCandidates())
835 }875 }
836 876
837 // createAuthService creates the authentication service and token store.877 // createAuthService creates the authentication service and token store.
A internal/app/run_internal_creds_test.go +81 -0
@@ -0,0 +1,81 @@
1+package app
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+)
8+
9+func TestGetInternalCredentialsFindsRuntimeFile(t *testing.T) {
10+ // Simulate daemon writing to XDG_RUNTIME_DIR/gordon/ while
11+ // CLI has no XDG_RUNTIME_DIR set.
12+ tmpRuntime := t.TempDir()
13+ gordonRuntime := filepath.Join(tmpRuntime, "gordon")
14+ if err := os.MkdirAll(gordonRuntime, 0700); err != nil {
15+ t.Fatal(err)
16+ }
17+
18+ // Write "live" creds to the runtime dir (as daemon would)
19+ liveCreds := `{"username":"gordon-internal","password":"livepassword"}`
20+ if err := os.WriteFile(filepath.Join(gordonRuntime, "internal-creds.json"), []byte(liveCreds), 0600); err != nil {
21+ t.Fatal(err)
22+ }
23+
24+ // Also write a stale creds file to home fallback
25+ tmpHome := t.TempDir()
26+ gordonHome := filepath.Join(tmpHome, ".gordon", "run")
27+ if err := os.MkdirAll(gordonHome, 0700); err != nil {
28+ t.Fatal(err)
29+ }
30+ staleCreds := `{"username":"gordon-internal","password":"stalepassword"}`
31+ if err := os.WriteFile(filepath.Join(gordonHome, "internal-creds.json"), []byte(staleCreds), 0600); err != nil {
32+ t.Fatal(err)
33+ }
34+
35+ // Unset XDG_RUNTIME_DIR so CLI path is taken, but probe gordonRuntime first
36+ t.Setenv("XDG_RUNTIME_DIR", "")
37+
38+ creds, err := GetInternalCredentialsFromCandidates([]string{
39+ filepath.Join(gordonRuntime, "internal-creds.json"),
40+ filepath.Join(gordonHome, "internal-creds.json"),
41+ })
42+ if err != nil {
43+ t.Fatalf("unexpected error: %v", err)
44+ }
45+ if creds.Password != "livepassword" {
46+ t.Errorf("got password %q, want %q", creds.Password, "livepassword")
47+ }
48+}
49+
50+func TestGetInternalCredentialsFallsBackToStale(t *testing.T) {
51+ // If only the stale/home file exists, it should still work
52+ tmpHome := t.TempDir()
53+ gordonHome := filepath.Join(tmpHome, ".gordon", "run")
54+ if err := os.MkdirAll(gordonHome, 0700); err != nil {
55+ t.Fatal(err)
56+ }
57+ staleCreds := `{"username":"gordon-internal","password":"stalepassword"}`
58+ if err := os.WriteFile(filepath.Join(gordonHome, "internal-creds.json"), []byte(staleCreds), 0600); err != nil {
59+ t.Fatal(err)
60+ }
61+
62+ creds, err := GetInternalCredentialsFromCandidates([]string{
63+ filepath.Join(t.TempDir(), "gordon", "internal-creds.json"), // doesn't exist
64+ filepath.Join(gordonHome, "internal-creds.json"),
65+ })
66+ if err != nil {
67+ t.Fatalf("unexpected error: %v", err)
68+ }
69+ if creds.Password != "stalepassword" {
70+ t.Errorf("got password %q, want %q", creds.Password, "stalepassword")
71+ }
72+}
73+
74+func TestGetInternalCredentialsNoCandidatesReturnsError(t *testing.T) {
75+ _, err := GetInternalCredentialsFromCandidates([]string{
76+ filepath.Join(t.TempDir(), "nonexistent", "internal-creds.json"),
77+ })
78+ if err == nil {
79+ t.Fatal("expected error when no credentials found, got nil")
80+ }
81+}
@@ -0,0 +1,81 @@
1+package app
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+)
8+
9+func TestGetInternalCredentialsFindsRuntimeFile(t *testing.T) {
10+ // Simulate daemon writing to XDG_RUNTIME_DIR/gordon/ while
11+ // CLI has no XDG_RUNTIME_DIR set.
12+ tmpRuntime := t.TempDir()
13+ gordonRuntime := filepath.Join(tmpRuntime, "gordon")
14+ if err := os.MkdirAll(gordonRuntime, 0700); err != nil {
15+ t.Fatal(err)
16+ }
17+
18+ // Write "live" creds to the runtime dir (as daemon would)
19+ liveCreds := `{"username":"gordon-internal","password":"livepassword"}`
20+ if err := os.WriteFile(filepath.Join(gordonRuntime, "internal-creds.json"), []byte(liveCreds), 0600); err != nil {
21+ t.Fatal(err)
22+ }
23+
24+ // Also write a stale creds file to home fallback
25+ tmpHome := t.TempDir()
26+ gordonHome := filepath.Join(tmpHome, ".gordon", "run")
27+ if err := os.MkdirAll(gordonHome, 0700); err != nil {
28+ t.Fatal(err)
29+ }
30+ staleCreds := `{"username":"gordon-internal","password":"stalepassword"}`
31+ if err := os.WriteFile(filepath.Join(gordonHome, "internal-creds.json"), []byte(staleCreds), 0600); err != nil {
32+ t.Fatal(err)
33+ }
34+
35+ // Unset XDG_RUNTIME_DIR so CLI path is taken, but probe gordonRuntime first
36+ t.Setenv("XDG_RUNTIME_DIR", "")
37+
38+ creds, err := GetInternalCredentialsFromCandidates([]string{
39+ filepath.Join(gordonRuntime, "internal-creds.json"),
40+ filepath.Join(gordonHome, "internal-creds.json"),
41+ })
42+ if err != nil {
43+ t.Fatalf("unexpected error: %v", err)
44+ }
45+ if creds.Password != "livepassword" {
46+ t.Errorf("got password %q, want %q", creds.Password, "livepassword")
47+ }
48+}
49+
50+func TestGetInternalCredentialsFallsBackToStale(t *testing.T) {
51+ // If only the stale/home file exists, it should still work
52+ tmpHome := t.TempDir()
53+ gordonHome := filepath.Join(tmpHome, ".gordon", "run")
54+ if err := os.MkdirAll(gordonHome, 0700); err != nil {
55+ t.Fatal(err)
56+ }
57+ staleCreds := `{"username":"gordon-internal","password":"stalepassword"}`
58+ if err := os.WriteFile(filepath.Join(gordonHome, "internal-creds.json"), []byte(staleCreds), 0600); err != nil {
59+ t.Fatal(err)
60+ }
61+
62+ creds, err := GetInternalCredentialsFromCandidates([]string{
63+ filepath.Join(t.TempDir(), "gordon", "internal-creds.json"), // doesn't exist
64+ filepath.Join(gordonHome, "internal-creds.json"),
65+ })
66+ if err != nil {
67+ t.Fatalf("unexpected error: %v", err)
68+ }
69+ if creds.Password != "stalepassword" {
70+ t.Errorf("got password %q, want %q", creds.Password, "stalepassword")
71+ }
72+}
73+
74+func TestGetInternalCredentialsNoCandidatesReturnsError(t *testing.T) {
75+ _, err := GetInternalCredentialsFromCandidates([]string{
76+ filepath.Join(t.TempDir(), "nonexistent", "internal-creds.json"),
77+ })
78+ if err == nil {
79+ t.Fatal("expected error when no credentials found, got nil")
80+ }
81+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close