Skip to content

Commit 7bca40e

Browse files
authored
Merge pull request #2830 from helixml/feature/codex_subs
Add ChatGPT subscription support for Codex agents
2 parents abb0e11 + a116186 commit 7bca40e

65 files changed

Lines changed: 5607 additions & 269 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Dockerfile.ubuntu-helix

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -964,6 +964,7 @@ RUN npm install -g \
964964
# for this file and opens the URL in the user's native browser.
965965
COPY desktop/shared/helix-capture-browser.sh /usr/local/bin/helix-capture-browser
966966
COPY desktop/shared/helix-claude-auth-wrapper.sh /usr/local/bin/helix-claude-auth-wrapper
967+
COPY desktop/shared/helix-codex-auth-wrapper.sh /usr/local/bin/helix-codex-auth-wrapper
967968
# helix-npx — installed as /usr/local/bin/npx so it shadows the system
968969
# /usr/bin/npx via PATH order. Gives each `npx <pkg>` invocation its own
969970
# NPM_CONFIG_CACHE so parallel npx spawns (Zed + Claude both starting
@@ -972,7 +973,7 @@ COPY desktop/shared/helix-claude-auth-wrapper.sh /usr/local/bin/helix-claude-aut
972973
# Zed's own ACP-wrapper bootstrapping calls npm via absolute path so it
973974
# bypasses this shim.
974975
COPY desktop/shared/helix-npx.sh /usr/local/bin/npx
975-
RUN chmod +x /usr/local/bin/helix-capture-browser /usr/local/bin/helix-claude-auth-wrapper /usr/local/bin/npx
976+
RUN chmod +x /usr/local/bin/helix-capture-browser /usr/local/bin/helix-claude-auth-wrapper /usr/local/bin/helix-codex-auth-wrapper /usr/local/bin/npx
976977

977978
# Install drone-ci-mcp (Helix's Drone CI MCP server for build log navigation)
978979
# Build and pack the package, then install globally from the tarball

api/cmd/settings-sync-daemon/main.go

Lines changed: 218 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
2425
var (
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

2931
const (
@@ -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
17201927
func (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

Comments
 (0)