@@ -17,13 +17,15 @@ import (
1717
1818 "github.com/fsnotify/fsnotify"
1919 "github.com/gorilla/websocket"
20+ "github.com/pelletier/go-toml/v2"
2021)
2122
2223// SettingsPath and KeymapPath are vars (not consts) so unit tests can point
2324// them at a tempdir without touching the real Zed config.
2425var (
25- SettingsPath = "/home/retro/.config/zed/settings.json"
26- KeymapPath = "/home/retro/.config/zed/keymap.json"
26+ SettingsPath = "/home/retro/.config/zed/settings.json"
27+ KeymapPath = "/home/retro/.config/zed/keymap.json"
28+ CodexConfigPath = "/home/retro/.codex/config.toml"
2729)
2830
2931const (
@@ -47,6 +49,7 @@ type SettingsDaemon struct {
4749
4850 // Whether user has a Claude subscription available for credential sync
4951 claudeSubscriptionAvailable bool
52+ codexSubscriptionAvailable bool
5053
5154 // Track the last expiresAt we know about, so we can detect Claude Code token refreshes
5255 lastKnownExpiresAt int64
@@ -55,7 +58,9 @@ type SettingsDaemon struct {
5558 claudeSetupToken string
5659
5760 // Timestamp of our last write to the credentials file (to ignore our own fsnotify events)
58- lastCredWrite time.Time
61+ lastCredWrite time.Time
62+ lastCodexCredWrite time.Time
63+ lastCodexRefresh time.Time
5964
6065 // Current state
6166 helixSettings map [string ]interface {}
@@ -115,6 +120,7 @@ type helixConfigResponse struct {
115120 Version int64 `json:"version"`
116121 CodeAgentConfig * CodeAgentConfig `json:"code_agent_config"`
117122 ClaudeSubscriptionAvailable bool `json:"claude_subscription_available,omitempty"`
123+ CodexSubscriptionAvailable bool `json:"codex_subscription_available,omitempty"`
118124}
119125
120126// generateAgentServerConfig creates the agent_servers configuration for custom agents (like qwen).
@@ -249,6 +255,38 @@ func (d *SettingsDaemon) generateAgentServerConfig() map[string]interface{} {
249255 "claude-acp" : claudeACPConfig ,
250256 }
251257
258+ case "codex_cli" :
259+ if err := ensureCodexNonInteractiveConfig (CodexConfigPath ); err != nil {
260+ log .Printf ("Failed to configure Codex non-interactive permissions: %v" , err )
261+ return nil
262+ }
263+ env := map [string ]interface {}{
264+ "CODEX_HOME" : "/home/retro/.codex" ,
265+ "INITIAL_AGENT_MODE" : "agent-full-access" ,
266+ }
267+ if d .codeAgentConfig .BaseURL != "" {
268+ env ["OPENAI_BASE_URL" ] = d .rewriteLocalhostURL (d .codeAgentConfig .BaseURL )
269+ if d .userAPIKey != "" {
270+ env ["OPENAI_API_KEY" ] = d .userAPIKey
271+ }
272+ } else {
273+ if _ , err := os .Stat (CodexCredentialsPath ); err != nil {
274+ log .Printf ("Codex credentials file not yet available, deferring codex agent server: %v" , err )
275+ return nil
276+ }
277+ env ["OPENAI_API_KEY" ] = ""
278+ env ["OPENAI_BASE_URL" ] = ""
279+ }
280+ config := map [string ]interface {}{
281+ "type" : "registry" ,
282+ "default_mode" : "agent-full-access" ,
283+ "env" : env ,
284+ }
285+ if d .codeAgentConfig .Model != "" {
286+ config ["default_model" ] = d .codeAgentConfig .Model
287+ }
288+ return map [string ]interface {}{"codex-acp" : config }
289+
252290 case "goose_code" :
253291 // Goose: Uses the `goose acp` command as a custom agent_server.
254292 // LLM provider/model are passed via GOOSE_PROVIDER + GOOSE_MODEL,
@@ -339,6 +377,36 @@ func (d *SettingsDaemon) generateAgentServerConfig() map[string]interface{} {
339377 }
340378}
341379
380+ func ensureCodexNonInteractiveConfig (path string ) error {
381+ config := map [string ]interface {}{}
382+ data , err := os .ReadFile (path )
383+ if err == nil {
384+ if err := toml .Unmarshal (data , & config ); err != nil {
385+ return fmt .Errorf ("parse existing Codex config: %w" , err )
386+ }
387+ } else if ! os .IsNotExist (err ) {
388+ return fmt .Errorf ("read existing Codex config: %w" , err )
389+ }
390+
391+ config ["approval_policy" ] = "never"
392+ config ["sandbox_mode" ] = "danger-full-access"
393+ data , err = toml .Marshal (config )
394+ if err != nil {
395+ return fmt .Errorf ("marshal Codex config: %w" , err )
396+ }
397+ if err := os .MkdirAll (filepath .Dir (path ), 0755 ); err != nil {
398+ return fmt .Errorf ("create Codex config directory: %w" , err )
399+ }
400+ tmpPath := path + ".tmp"
401+ if err := os .WriteFile (tmpPath , data , 0644 ); err != nil {
402+ return fmt .Errorf ("write Codex config: %w" , err )
403+ }
404+ if err := os .Rename (tmpPath , path ); err != nil {
405+ return fmt .Errorf ("install Codex config: %w" , err )
406+ }
407+ return nil
408+ }
409+
342410// writeGooseConfig writes ${xdgConfigHome}/goose/config.yaml so the goose acp
343411// process picks up our slash_commands. We use a dedicated XDG_CONFIG_HOME
344412// (set on the agent_servers env) to avoid clobbering any user-level goose
@@ -422,23 +490,22 @@ func (d *SettingsDaemon) rewriteLocalhostURL(originalURL string) string {
422490 // Parse our known-working API URL to get the host
423491 apiParsed , err := url .Parse (d .apiURL )
424492 if err != nil {
425- log .Printf ("Warning: failed to parse apiURL %s: %v" , d . apiURL , err )
493+ log .Printf ("Warning: failed to parse API URL" )
426494 return originalURL
427495 }
428496
429497 // Parse the original URL
430498 origParsed , err := url .Parse (originalURL )
431499 if err != nil {
432- log .Printf ("Warning: failed to parse original URL %s: %v" , originalURL , err )
500+ log .Printf ("Warning: failed to parse model endpoint URL" )
433501 return originalURL
434502 }
435503
436504 // Replace the host with our working API host
437505 origParsed .Host = apiParsed .Host
438506
439- rewritten := origParsed .String ()
440- log .Printf ("Rewrote localhost URL for container networking: %s -> %s" , originalURL , rewritten )
441- return rewritten
507+ log .Printf ("Rewrote localhost URL for container networking" )
508+ return origParsed .String ()
442509}
443510
444511// injectAvailableModels adds the configured model to the provider's available_models list.
@@ -567,6 +634,7 @@ const (
567634 ClaudeCredentialsPath = "/home/retro/.claude/.credentials.json"
568635 ClaudeSubscriptionMarkerPath = "/tmp/helix-claude-subscription-mode"
569636 ClaudeManagedSettingsPath = "/etc/claude-code/managed-settings.json"
637+ CodexCredentialsPath = "/home/retro/.codex/auth.json"
570638)
571639
572640// writeClaudeManagedSettings writes /etc/claude-code/managed-settings.json so the
@@ -798,6 +866,121 @@ func (d *SettingsDaemon) pushCredentialsToAPI() {
798866 log .Printf ("Pushed refreshed Claude credentials to API (expiresAt=%d)" , creds .ExpiresAt )
799867}
800868
869+ type codexAuthCredentials struct {
870+ AuthMode string `json:"auth_mode"`
871+ OpenAIAPIKey * string `json:"OPENAI_API_KEY"`
872+ Tokens struct {
873+ IDToken string `json:"id_token"`
874+ AccessToken string `json:"access_token"`
875+ RefreshToken string `json:"refresh_token"`
876+ AccountID string `json:"account_id"`
877+ } `json:"tokens"`
878+ LastRefresh time.Time `json:"last_refresh"`
879+ }
880+
881+ func (d * SettingsDaemon ) syncCodexCredentials () {
882+ if ! d .codexSubscriptionAvailable {
883+ return
884+ }
885+ apiURL := fmt .Sprintf ("%s/api/v1/sessions/%s/codex-credentials" , d .apiURL , d .sessionID )
886+ req , err := http .NewRequestWithContext (context .Background (), http .MethodGet , apiURL , nil )
887+ if err != nil {
888+ log .Printf ("Failed to create Codex credentials request: %v" , err )
889+ return
890+ }
891+ if d .apiToken != "" {
892+ req .Header .Set ("Authorization" , "Bearer " + d .apiToken )
893+ }
894+ resp , err := d .httpClient .Do (req )
895+ if err != nil {
896+ log .Printf ("Failed to fetch Codex credentials: %v" , err )
897+ return
898+ }
899+ defer resp .Body .Close ()
900+ if resp .StatusCode != http .StatusOK {
901+ log .Printf ("Failed to fetch Codex credentials: status %d" , resp .StatusCode )
902+ return
903+ }
904+ var serverCredentials codexAuthCredentials
905+ if err := json .NewDecoder (resp .Body ).Decode (& serverCredentials ); err != nil {
906+ log .Printf ("Failed to parse Codex credentials: %v" , err )
907+ return
908+ }
909+ if fileCredentials , err := readCodexCredentials (); err == nil && fileCredentials .LastRefresh .After (serverCredentials .LastRefresh ) {
910+ d .pushCodexCredentialsToAPI ()
911+ return
912+ }
913+ data , err := json .MarshalIndent (serverCredentials , "" , " " )
914+ if err != nil {
915+ log .Printf ("Failed to marshal Codex credentials: %v" , err )
916+ return
917+ }
918+ credentialDir := filepath .Dir (CodexCredentialsPath )
919+ if err := os .MkdirAll (credentialDir , 0700 ); err != nil {
920+ log .Printf ("Failed to create Codex credentials directory: %v" , err )
921+ return
922+ }
923+ tmpPath := CodexCredentialsPath + ".tmp"
924+ if err := os .WriteFile (tmpPath , data , 0600 ); err != nil {
925+ log .Printf ("Failed to write Codex credentials: %v" , err )
926+ return
927+ }
928+ if err := os .Rename (tmpPath , CodexCredentialsPath ); err != nil {
929+ log .Printf ("Failed to install Codex credentials: %v" , err )
930+ return
931+ }
932+ d .lastCodexRefresh = serverCredentials .LastRefresh
933+ d .lastCodexCredWrite = time .Now ()
934+ log .Printf ("Synced Codex credentials to %s" , CodexCredentialsPath )
935+ }
936+
937+ func readCodexCredentials () (* codexAuthCredentials , error ) {
938+ data , err := os .ReadFile (CodexCredentialsPath )
939+ if err != nil {
940+ return nil , err
941+ }
942+ var credentials codexAuthCredentials
943+ if err := json .Unmarshal (data , & credentials ); err != nil {
944+ return nil , err
945+ }
946+ if credentials .AuthMode != "chatgpt" || credentials .Tokens .RefreshToken == "" || credentials .LastRefresh .IsZero () {
947+ return nil , fmt .Errorf ("invalid Codex credentials" )
948+ }
949+ return & credentials , nil
950+ }
951+
952+ func (d * SettingsDaemon ) pushCodexCredentialsToAPI () {
953+ credentials , err := readCodexCredentials ()
954+ if err != nil || ! credentials .LastRefresh .After (d .lastCodexRefresh ) {
955+ return
956+ }
957+ payload , err := json .Marshal (credentials )
958+ if err != nil {
959+ return
960+ }
961+ apiURL := fmt .Sprintf ("%s/api/v1/sessions/%s/codex-credentials" , d .apiURL , d .sessionID )
962+ req , err := http .NewRequestWithContext (context .Background (), http .MethodPut , apiURL , bytes .NewReader (payload ))
963+ if err != nil {
964+ return
965+ }
966+ req .Header .Set ("Content-Type" , "application/json" )
967+ if d .apiToken != "" {
968+ req .Header .Set ("Authorization" , "Bearer " + d .apiToken )
969+ }
970+ resp , err := d .httpClient .Do (req )
971+ if err != nil {
972+ log .Printf ("Failed to push Codex credentials: %v" , err )
973+ return
974+ }
975+ defer resp .Body .Close ()
976+ if resp .StatusCode != http .StatusOK {
977+ log .Printf ("Failed to push Codex credentials: status %d" , resp .StatusCode )
978+ return
979+ }
980+ d .lastCodexRefresh = credentials .LastRefresh
981+ log .Printf ("Pushed refreshed Codex credentials to API" )
982+ }
983+
801984// writeZedKeymap writes Zed keymap.json with terminal copy/paste bindings.
802985// XKB remaps Super (Command) → Ctrl, so we configure Zed's terminal to:
803986// - Ctrl+C: copy when text is selected, SIGINT when not (via context precedence)
@@ -966,9 +1149,11 @@ func (d *SettingsDaemon) syncFromHelix() error {
9661149 // Store code agent config for generating agent_servers
9671150 d .codeAgentConfig = config .CodeAgentConfig
9681151 d .claudeSubscriptionAvailable = config .ClaudeSubscriptionAvailable
1152+ d .codexSubscriptionAvailable = config .CodexSubscriptionAvailable
9691153
9701154 // Sync Claude credentials if available
9711155 d .syncClaudeCredentials ()
1156+ d .syncCodexCredentials ()
9721157
9731158 // Start from hardcoded Helix defaults, then layer on API response fields
9741159 d .helixSettings = helixDefaults ()
@@ -1668,24 +1853,39 @@ func (d *SettingsDaemon) startWatcher() error {
16681853 log .Printf ("Watching %s for credential refreshes" , credDir )
16691854 }
16701855 }
1856+ if d .codexSubscriptionAvailable {
1857+ credDir := filepath .Dir (CodexCredentialsPath )
1858+ if err := os .MkdirAll (credDir , 0700 ); err != nil {
1859+ log .Printf ("Warning: failed to create Codex credentials directory for watcher: %v" , err )
1860+ } else if err := watcher .Add (credDir ); err != nil {
1861+ log .Printf ("Warning: failed to watch Codex credentials directory: %v" , err )
1862+ }
1863+ }
16711864
16721865 go func () {
16731866 var settingsDebounce * time.Timer
16741867 var credsDebounce * time.Timer
1868+ var codexCredsDebounce * time.Timer
16751869 credFilename := filepath .Base (ClaudeCredentialsPath )
1870+ codexCredFilename := filepath .Base (CodexCredentialsPath )
16761871
16771872 for {
16781873 select {
16791874 case event := <- watcher .Events :
16801875 if event .Op & (fsnotify .Write | fsnotify .Create ) != 0 {
1681- if filepath .Base (event .Name ) == credFilename {
1876+ if filepath .Base (event .Name ) == credFilename && filepath . Dir ( event . Name ) == filepath . Dir ( ClaudeCredentialsPath ) {
16821877 // Claude credentials file changed
16831878 if credsDebounce != nil {
16841879 credsDebounce .Stop ()
16851880 }
16861881 credsDebounce = time .AfterFunc (DebounceTime , func () {
16871882 d .onCredentialsChanged ()
16881883 })
1884+ } else if filepath .Base (event .Name ) == codexCredFilename && filepath .Dir (event .Name ) == filepath .Dir (CodexCredentialsPath ) {
1885+ if codexCredsDebounce != nil {
1886+ codexCredsDebounce .Stop ()
1887+ }
1888+ codexCredsDebounce = time .AfterFunc (DebounceTime , d .onCodexCredentialsChanged )
16891889 } else if filepath .Base (event .Name ) == filepath .Base (SettingsPath ) {
16901890 // Zed settings file changed
16911891 if settingsDebounce != nil {
@@ -1716,6 +1916,13 @@ func (d *SettingsDaemon) onCredentialsChanged() {
17161916 d .pushCredentialsToAPI ()
17171917}
17181918
1919+ func (d * SettingsDaemon ) onCodexCredentialsChanged () {
1920+ if time .Since (d .lastCodexCredWrite ) < 2 * time .Second {
1921+ return
1922+ }
1923+ d .pushCodexCredentialsToAPI ()
1924+ }
1925+
17191926// onFileChanged handles Zed UI modifications to settings.json
17201927func (d * SettingsDaemon ) onFileChanged () {
17211928 // Prevent re-triggering on our own writes
@@ -1848,7 +2055,9 @@ func (d *SettingsDaemon) checkHelixUpdates() error {
18482055
18492056 // Update Claude subscription availability and sync credentials
18502057 d .claudeSubscriptionAvailable = config .ClaudeSubscriptionAvailable
2058+ d .codexSubscriptionAvailable = config .CodexSubscriptionAvailable
18512059 d .syncClaudeCredentials ()
2060+ d .syncCodexCredentials ()
18522061
18532062 // Compare against the pre-injection baseline to avoid spurious diffs
18542063 // caused by injectAvailableModels mutations
0 commit comments