Skip to content

Commit 21e11d5

Browse files
authored
fix: add codex native proxy config and universal gateway skill fallback (#75)
1 parent d7d6c00 commit 21e11d5

5 files changed

Lines changed: 231 additions & 38 deletions

File tree

cmd/onecli/org_secrets.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ func (c *OrgSecretsListCmd) Run(out *output.Writer) error {
4747
// OrgSecretsCreateCmd is `onecli org secrets create`.
4848
type OrgSecretsCreateCmd struct {
4949
Name string `required:"" help:"Display name for the secret."`
50-
Type string `required:"" help:"Secret type: 'anthropic', 'openai', 'codex', or 'generic'."`
50+
Type string `required:"" help:"Secret type: 'anthropic', 'openai', or 'generic'."`
5151
Value string `optional:"" help:"Secret value (e.g. API key). Required unless --file is provided."`
5252
File string `optional:"" name:"file" type:"existingfile" help:"Read secret value from a file (e.g. ~/.codex/auth.json)."`
5353
HostPattern string `required:"" name:"host-pattern" help:"Host pattern to match (e.g. 'api.anthropic.com')."`
@@ -104,8 +104,8 @@ func (c *OrgSecretsCreateCmd) Run(out *output.Writer) error {
104104
}
105105
}
106106

107-
if input.Type != "anthropic" && input.Type != "openai" && input.Type != "codex" && input.Type != "generic" {
108-
return fmt.Errorf("invalid type %q: must be 'anthropic', 'openai', 'codex', or 'generic'", input.Type)
107+
if input.Type != "anthropic" && input.Type != "openai" && input.Type != "generic" {
108+
return fmt.Errorf("invalid type %q: must be 'anthropic', 'openai', or 'generic'", input.Type)
109109
}
110110

111111
if c.DryRun {

cmd/onecli/run.go

Lines changed: 154 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"strings"
1515
"syscall"
1616

17+
"github.com/onecli/onecli-cli/internal/api"
1718
"github.com/onecli/onecli-cli/internal/config"
1819
"github.com/onecli/onecli-cli/pkg/output"
1920
"github.com/onecli/onecli-cli/pkg/validate"
@@ -107,22 +108,46 @@ func (c *RunCmd) Run(out *output.Writer) error {
107108
// Build child environment.
108109
env := buildChildEnv(os.Environ(), cfg.Env, caPath)
109110

110-
// Install skill and hook for known agents (silently updates stale files).
111-
// Fetch the latest skill from the API; fall back to the embedded copy.
112-
if name, dir, cfgDir, ok := agentSkillDir(c.Args[0]); ok {
111+
env = append(env, "ONECLI_GATEWAY=true")
112+
113+
// For known agents, fetch the agent-specific skill variant and install
114+
// to the agent's skill directory. Also optionally register a hook.
115+
agentFramework := strings.ToLower(filepath.Base(c.Args[0]))
116+
if name, dir, cfgDir, noHook, _, nativeProxy, ok := agentSkillDir(c.Args[0]); ok {
113117
skillContent := gatewaySkillFallback
114118
if fetched, err := client.GetGatewaySkill(newContext()); err == nil && fetched != "" {
115119
skillContent = fetched
116120
}
117121
maybeInstallGatewaySkill(out, name, dir, skillContent)
118-
maybeInstallGatewayHook(out, name, dir)
122+
if !noHook {
123+
maybeInstallGatewayHook(out, name, dir)
124+
}
119125

120126
// Electron-based agents (e.g. Cursor) ignore embedded user:pass in
121127
// HTTPS_PROXY and show a native auth dialog. Inject proxy credentials
122128
// into the app's VS Code-style settings.json instead.
123129
if cfgDir != "" {
124130
env = injectElectronProxySettings(out, env, cfgDir, caPath)
125131
}
132+
133+
// Agents with a native proxy config (e.g. Codex) need proxy_url
134+
// written to their TOML config and CODEX_CA_CERTIFICATE set.
135+
if nativeProxy != "" {
136+
maybeInjectNativeProxyConfig(out, name, nativeProxy, env, caPath)
137+
}
138+
if agentFramework == "codex" {
139+
maybeCreateCodexAuthStub(out, client)
140+
}
141+
} else {
142+
// Unknown agent — install the skill to ~/.onecli/skills/ so the
143+
// framework can discover it via ONECLI_GATEWAY_SKILL_PATH.
144+
skillContent := gatewaySkillFallback
145+
if fetched, err := client.GetGatewaySkill(newContext()); err == nil && fetched != "" {
146+
skillContent = fetched
147+
}
148+
if p := installUniversalGatewaySkill(out, skillContent); p != "" {
149+
env = append(env, "ONECLI_GATEWAY_SKILL_PATH="+p)
150+
}
126151
}
127152

128153
// Surface any warnings from the server (e.g. missing credentials).
@@ -314,30 +339,33 @@ func rewriteProxyEnvHosts(env map[string]string, localHost string) {
314339

315340
// supportedAgents maps CLI binary base-names to (agentName, skillsBaseDir) pairs.
316341
var supportedAgents = []struct {
317-
bases []string
318-
agentName string
319-
baseDir string
320-
configDir string // VS Code-style config dir name; non-empty enables proxy settings injection.
342+
bases []string
343+
agentName string
344+
baseDir string
345+
configDir string // VS Code-style config dir name; non-empty enables proxy settings injection.
346+
skipHook bool // true for agents that don't support Claude Code-style hooks.
347+
hasPlugin bool // true for agents that support a transform_tool_result plugin.
348+
nativeProxyConfig string // home-relative dir containing a TOML config that needs proxy_url injection (e.g. ".codex").
321349
}{
322-
{[]string{"claude"}, "Claude Code", ".claude", ""},
323-
{[]string{"cursor", "agent"}, "Cursor", ".cursor", "Cursor"},
324-
{[]string{"codex"}, "Codex", ".agents", ""},
325-
{[]string{"hermes"}, "Hermes", ".hermes", ""},
326-
{[]string{"opencode"}, "OpenCode", ".opencode", ""},
350+
{[]string{"claude"}, "Claude Code", ".claude", "", false, false, ""},
351+
{[]string{"cursor", "agent"}, "Cursor", ".cursor", "Cursor", false, false, ""},
352+
{[]string{"codex"}, "Codex", ".agents", "", false, false, ".codex"},
353+
{[]string{"hermes"}, "Hermes", ".hermes", "", true, true, ""},
354+
{[]string{"opencode"}, "OpenCode", ".opencode", "", false, false, ""},
327355
}
328356

329-
// agentSkillDir returns the display name and skills base directory for a known
330-
// agent command, or ok=false if the command is not recognized.
331-
func agentSkillDir(cmd string) (agentName, baseDir, configDir string, ok bool) {
357+
// agentSkillDir returns the display name, skills base directory, and config
358+
// options for a known agent command, or ok=false if the command is not recognized.
359+
func agentSkillDir(cmd string) (agentName, baseDir, configDir string, skipHook bool, hasPlugin bool, nativeProxyConfig string, ok bool) {
332360
base := filepath.Base(cmd)
333361
for _, a := range supportedAgents {
334362
for _, b := range a.bases {
335363
if base == b {
336-
return a.agentName, a.baseDir, a.configDir, true
364+
return a.agentName, a.baseDir, a.configDir, a.skipHook, a.hasPlugin, a.nativeProxyConfig, true
337365
}
338366
}
339367
}
340-
return "", "", "", false
368+
return "", "", "", false, false, "", false
341369
}
342370

343371
// maybeInstallGatewaySkill installs the OneCLI gateway skill file if it is
@@ -366,6 +394,114 @@ func maybeInstallGatewaySkill(out *output.Writer, agentName, baseDir, content st
366394
out.Stderr(fmt.Sprintf("onecli: installed gateway skill for %s.", agentName))
367395
}
368396

397+
// installUniversalGatewaySkill writes the gateway skill to
398+
// ~/.onecli/skills/gateway.md so any framework can reference it via
399+
// the ONECLI_GATEWAY_SKILL_PATH env var. Returns the path on success.
400+
func installUniversalGatewaySkill(out *output.Writer, content string) string {
401+
home, err := os.UserHomeDir()
402+
if err != nil {
403+
return ""
404+
}
405+
fullPath := filepath.Join(home, ".onecli", "skills", "gateway.md")
406+
407+
existing, err := os.ReadFile(fullPath)
408+
if err == nil && bytes.Equal(existing, []byte(content)) {
409+
return fullPath
410+
}
411+
412+
if err := os.MkdirAll(filepath.Dir(fullPath), 0o750); err != nil {
413+
out.Stderr(fmt.Sprintf("onecli: warning: could not create universal skill directory: %v", err))
414+
return ""
415+
}
416+
if err := os.WriteFile(fullPath, []byte(content), 0o600); err != nil {
417+
out.Stderr(fmt.Sprintf("onecli: warning: could not write universal skill file: %v", err))
418+
return ""
419+
}
420+
return fullPath
421+
}
422+
423+
// codexAuthStub is the auth.json stub written to ~/.codex/auth.json when the
424+
// file does not exist. The id_token is a structurally valid JWT with email and
425+
// plan_type claims so Codex's local validation passes. Real credentials are
426+
// injected at the gateway proxy level.
427+
const codexAuthStub = `{
428+
"auth_mode": "chatgpt",
429+
"OPENAI_API_KEY": null,
430+
"tokens": {
431+
"id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJvbmVjbGktbWFuYWdlZCIsImVtYWlsIjoib25lY2xpQG9uZWNsaS5zaCIsImV4cCI6NDEwMjQ0NDgwMCwiaWF0IjoxNzM1Njg5NjAwLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9wbGFuX3R5cGUiOiJmcmVlIiwiY2hhdGdwdF91c2VyX2lkIjoib25lY2xpLW1hbmFnZWQiLCJjaGF0Z3B0X2FjY291bnRfaWQiOiJvbmVjbGktbWFuYWdlZCJ9fQ.b25lY2xpLW1hbmFnZWQtc2lnbmF0dXJl",
432+
"access_token": "onecli-managed",
433+
"refresh_token": "onecli-managed",
434+
"account_id": "onecli-managed"
435+
},
436+
"last_refresh": "2025-01-01T00:00:00Z"
437+
}
438+
`
439+
440+
// maybeCreateCodexAuthStub creates ~/.codex/auth.json with onecli-managed
441+
// placeholder values if the file does not already exist. Fetches the latest
442+
// stub from the API; falls back to the embedded constant.
443+
func maybeCreateCodexAuthStub(out *output.Writer, client *api.Client) {
444+
home, err := os.UserHomeDir()
445+
if err != nil {
446+
return
447+
}
448+
authPath := filepath.Join(home, ".codex", "auth.json")
449+
if _, err := os.Stat(authPath); err == nil {
450+
return
451+
}
452+
453+
content := codexAuthStub
454+
if stub, err := client.GetCredentialStub(newContext(), "codex"); err == nil && stub.Content != "" {
455+
content = stub.Content
456+
}
457+
458+
if err := os.MkdirAll(filepath.Dir(authPath), 0o750); err != nil {
459+
out.Stderr(fmt.Sprintf("onecli: warning: could not create .codex directory: %v", err))
460+
return
461+
}
462+
if err := os.WriteFile(authPath, []byte(content), 0o600); err != nil {
463+
out.Stderr(fmt.Sprintf("onecli: warning: could not write codex auth stub: %v", err))
464+
return
465+
}
466+
out.Stderr("onecli: created ~/.codex/auth.json stub for gateway auth.")
467+
}
468+
469+
// maybeInjectNativeProxyConfig writes proxy_url into a TOML config file for
470+
// agents that have their own managed proxy (e.g. Codex). Also sets the
471+
// agent-specific CA certificate env var.
472+
func maybeInjectNativeProxyConfig(out *output.Writer, agentName, configRelDir string, env []string, caPath string) {
473+
home, err := os.UserHomeDir()
474+
if err != nil {
475+
return
476+
}
477+
478+
proxyURL := findProxyURL(env)
479+
if proxyURL == "" {
480+
return
481+
}
482+
483+
configPath := filepath.Join(home, configRelDir, "config.toml")
484+
data, _ := os.ReadFile(configPath)
485+
content := string(data)
486+
487+
// Inject [network] section with proxy_url if not already present.
488+
if !strings.Contains(content, "proxy_url") {
489+
section := "\n[network]\nproxy_url = \"" + proxyURL + "\"\n"
490+
content += section
491+
if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil {
492+
out.Stderr(fmt.Sprintf("onecli: warning: could not write proxy config for %s: %v", agentName, err))
493+
return
494+
}
495+
out.Stderr(fmt.Sprintf("onecli: configured native proxy for %s.", agentName))
496+
}
497+
498+
// Set CODEX_CA_CERTIFICATE if we have a CA path — Codex reads this
499+
// in addition to SSL_CERT_FILE for its Rust TLS client.
500+
if caPath != "" {
501+
os.Setenv("CODEX_CA_CERTIFICATE", caPath)
502+
}
503+
}
504+
369505
// maybeInstallGatewayHook installs the gateway detection hook script and
370506
// registers it in the agent's settings.json so the agent knows the gateway
371507
// is active without needing to run any visible checks.

cmd/onecli/run_test.go

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -97,24 +97,27 @@ func TestStripProxyCredentials(t *testing.T) {
9797

9898
func TestAgentSkillDir(t *testing.T) {
9999
tests := []struct {
100-
cmd string
101-
wantName string
102-
wantDir string
103-
wantCfg string
104-
wantOK bool
100+
cmd string
101+
wantName string
102+
wantDir string
103+
wantCfg string
104+
wantSkipHook bool
105+
wantPlugin bool
106+
wantNativeProxy string
107+
wantOK bool
105108
}{
106-
{"claude", "Claude Code", ".claude", "", true},
107-
{"cursor", "Cursor", ".cursor", "Cursor", true},
108-
{"agent", "Cursor", ".cursor", "Cursor", true},
109-
{"codex", "Codex", ".agents", "", true},
110-
{"hermes", "Hermes", ".hermes", "", true},
111-
{"opencode", "OpenCode", ".opencode", "", true},
112-
{"/usr/local/bin/cursor", "Cursor", ".cursor", "Cursor", true},
113-
{"unknown", "", "", "", false},
109+
{"claude", "Claude Code", ".claude", "", false, false, "", true},
110+
{"cursor", "Cursor", ".cursor", "Cursor", false, false, "", true},
111+
{"agent", "Cursor", ".cursor", "Cursor", false, false, "", true},
112+
{"codex", "Codex", ".agents", "", false, false, ".codex", true},
113+
{"hermes", "Hermes", ".hermes", "", true, true, "", true},
114+
{"opencode", "OpenCode", ".opencode", "", false, false, "", true},
115+
{"/usr/local/bin/cursor", "Cursor", ".cursor", "Cursor", false, false, "", true},
116+
{"unknown", "", "", "", false, false, "", false},
114117
}
115118
for _, tt := range tests {
116119
t.Run(tt.cmd, func(t *testing.T) {
117-
name, dir, cfg, ok := agentSkillDir(tt.cmd)
120+
name, dir, cfg, skipHook, plugin, nativeProxy, ok := agentSkillDir(tt.cmd)
118121
if ok != tt.wantOK {
119122
t.Fatalf("ok = %v, want %v", ok, tt.wantOK)
120123
}
@@ -127,6 +130,15 @@ func TestAgentSkillDir(t *testing.T) {
127130
if cfg != tt.wantCfg {
128131
t.Errorf("configDir = %q, want %q", cfg, tt.wantCfg)
129132
}
133+
if skipHook != tt.wantSkipHook {
134+
t.Errorf("skipHook = %v, want %v", skipHook, tt.wantSkipHook)
135+
}
136+
if plugin != tt.wantPlugin {
137+
t.Errorf("hasPlugin = %v, want %v", plugin, tt.wantPlugin)
138+
}
139+
if nativeProxy != tt.wantNativeProxy {
140+
t.Errorf("nativeProxyConfig = %q, want %q", nativeProxy, tt.wantNativeProxy)
141+
}
130142
})
131143
}
132144
}

cmd/onecli/secrets.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ func (c *SecretsListCmd) Run(out *output.Writer) error {
5353
type SecretsCreateCmd struct {
5454
Project string `optional:"" short:"p" help:"Project slug."`
5555
Name string `required:"" help:"Display name for the secret."`
56-
Type string `required:"" help:"Secret type: 'anthropic', 'openai', 'codex', or 'generic'."`
56+
Type string `required:"" help:"Secret type: 'anthropic', 'openai', or 'generic'."`
5757
Value string `optional:"" help:"Secret value (e.g. API key). Required unless --file is provided."`
5858
File string `optional:"" name:"file" type:"existingfile" help:"Read secret value from a file (e.g. ~/.codex/auth.json)."`
5959
HostPattern string `required:"" name:"host-pattern" help:"Host pattern to match (e.g. 'api.anthropic.com')."`
@@ -110,8 +110,8 @@ func (c *SecretsCreateCmd) Run(out *output.Writer) error {
110110
}
111111
}
112112

113-
if input.Type != "anthropic" && input.Type != "openai" && input.Type != "codex" && input.Type != "generic" {
114-
return fmt.Errorf("invalid type %q: must be 'anthropic', 'openai', 'codex', or 'generic'", input.Type)
113+
if input.Type != "anthropic" && input.Type != "openai" && input.Type != "generic" {
114+
return fmt.Errorf("invalid type %q: must be 'anthropic', 'openai', or 'generic'", input.Type)
115115
}
116116

117117
if c.DryRun {

internal/api/credential_stub.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package api
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
8+
)
9+
10+
// CredentialStub is the response from the credential-stubs API.
11+
type CredentialStub struct {
12+
Agent string `json:"agent"`
13+
FilePath string `json:"filePath"`
14+
Content string `json:"content"`
15+
Permissions string `json:"permissions"`
16+
}
17+
18+
// GetCredentialStub fetches a credential stub for the given agent from the API.
19+
func (c *Client) GetCredentialStub(ctx context.Context, agent string) (*CredentialStub, error) {
20+
c.resolvePrefix(ctx)
21+
path := c.applyPrefix("/v1/credential-stubs/" + agent)
22+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
23+
if err != nil {
24+
return nil, fmt.Errorf("creating request: %w", err)
25+
}
26+
if c.apiKey != "" {
27+
req.Header.Set("Authorization", "Bearer "+c.apiKey)
28+
}
29+
30+
resp, err := c.httpClient.Do(req)
31+
if err != nil {
32+
return nil, fmt.Errorf("fetching credential stub: %w", err)
33+
}
34+
defer resp.Body.Close()
35+
36+
if resp.StatusCode >= 400 {
37+
return nil, &APIError{StatusCode: resp.StatusCode, Message: fmt.Sprintf("credential-stubs endpoint returned %d", resp.StatusCode)}
38+
}
39+
40+
var stub CredentialStub
41+
if err := json.NewDecoder(resp.Body).Decode(&stub); err != nil {
42+
return nil, fmt.Errorf("decoding credential stub: %w", err)
43+
}
44+
return &stub, nil
45+
}

0 commit comments

Comments
 (0)