Skip to content

Commit 418ac07

Browse files
committed
Add server auth sync and refresh OAuth tokens
1 parent 23a401f commit 418ac07

14 files changed

Lines changed: 785 additions & 40 deletions

File tree

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,8 @@ It does not edit Codex config or set auth environment variables. Do not set a du
156156

157157
Override the subrouter URL with `SUBROUTER_CODEX_BASE_URL` if needed. See [docs/codex.md](docs/codex.md) for details and the custom-provider fallback.
158158

159+
If `SUBROUTER_CODEX_BASE_URL` is not set, the wrapper uses local `127.0.0.1:31415` when it is a healthy Subrouter. If that local listener is missing or stale and exactly one server is configured with `sr server add`, `sr codex` uses that server instead.
160+
159161
Set `SUBROUTER_CODEX_USER_EMAIL` to attribute Codex traffic to a teammate:
160162

161163
```bash
@@ -179,6 +181,20 @@ Subrouter has a native Go implementation of the Codex account manager. It reads
179181
~/.codex-accounts/accounts/*.json
180182
```
181183

184+
Server-owned OAuth accounts must be created with fresh logins because Codex refresh tokens rotate. Do not copy local OAuth account files to a server. To compare local OAuth emails with a configured server and reauth missing accounts on the server, run:
185+
186+
```bash
187+
sr server sync community --device-auth
188+
```
189+
190+
To only show the diff:
191+
192+
```bash
193+
sr server diff community
194+
```
195+
196+
Use `--email you@example.com` to reauth one local email, or `--all` to replace every local OAuth email on the server with a new server-owned refresh-token chain.
197+
182198
Account-management commands are built into the `subrouter` binary:
183199

184200
```bash

cmd/subrouter/codex.go

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,25 @@ package main
33
import (
44
"context"
55
"fmt"
6+
"net/http"
67
"os"
78
"os/exec"
89
"strconv"
910
"strings"
11+
"time"
1012

13+
"github.com/manaflow-ai/subrouter/internal/accounts"
1114
"github.com/manaflow-ai/subrouter/internal/session"
1215
)
1316

1417
const defaultCodexBaseURL = "http://127.0.0.1:31415/v1"
18+
const defaultSubrouterHealthURL = "http://127.0.0.1:31415/_subrouter/health"
1519

1620
func codex(args []string) error {
17-
baseURL := envOrDefault("SUBROUTER_CODEX_BASE_URL", defaultCodexBaseURL)
21+
baseURL := os.Getenv("SUBROUTER_CODEX_BASE_URL")
22+
if baseURL == "" {
23+
baseURL = defaultCodexBaseURLFor(defaultSubrouterHealthURL, defaultCXServerStore(accounts.DefaultCodexStore()), &http.Client{Timeout: 500 * time.Millisecond})
24+
}
1825
bin := envOrDefault("SUBROUTER_CODEX_BIN", "codex")
1926
userEmailRaw := os.Getenv("SUBROUTER_CODEX_USER_EMAIL")
2027
accountID := session.NormalizeAccountID(os.Getenv("SUBROUTER_CODEX_ACCOUNT_ID"))
@@ -38,6 +45,41 @@ func codex(args []string) error {
3845
return cmd.Run()
3946
}
4047

48+
func defaultCodexBaseURLFor(localHealthURL string, store cxServerStore, client *http.Client) string {
49+
if subrouterHealthOK(localHealthURL, client) {
50+
return defaultCodexBaseURL
51+
}
52+
file, err := store.load()
53+
if err != nil || len(file.Servers) != 1 {
54+
return defaultCodexBaseURL
55+
}
56+
return codexBaseURLForServer(file.Servers[0])
57+
}
58+
59+
func subrouterHealthOK(healthURL string, client *http.Client) bool {
60+
if client == nil {
61+
client = &http.Client{Timeout: 500 * time.Millisecond}
62+
}
63+
req, err := http.NewRequest(http.MethodGet, healthURL, nil)
64+
if err != nil {
65+
return false
66+
}
67+
res, err := client.Do(req)
68+
if err != nil {
69+
return false
70+
}
71+
defer res.Body.Close()
72+
return res.StatusCode >= 200 && res.StatusCode < 300
73+
}
74+
75+
func codexBaseURLForServer(server cxServerConfig) string {
76+
baseURL := strings.TrimRight(server.URL, "/")
77+
if strings.HasSuffix(baseURL, "/v1") {
78+
return baseURL
79+
}
80+
return baseURL + "/v1"
81+
}
82+
4183
func codexArgs(args []string, baseURL, userEmail, accountID string) []string {
4284
configArgs := codexConfigArgs(baseURL, userEmail, accountID)
4385
if len(args) == 0 || strings.HasPrefix(args[0], "-") || !isKnownCodexCommand(args[0]) {

cmd/subrouter/codex_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package main
22

33
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"path/filepath"
47
"strings"
58
"testing"
69
)
@@ -13,6 +16,63 @@ func TestCodexArgsInjectsSubrouterBaseURLAsGlobalConfig(t *testing.T) {
1316
}
1417
}
1518

19+
func TestDefaultCodexBaseURLUsesConfiguredServerWhenLocalHealthFails(t *testing.T) {
20+
local := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
21+
http.Error(w, "old gateway", http.StatusForbidden)
22+
}))
23+
t.Cleanup(local.Close)
24+
store := cxServerStore{Path: filepath.Join(t.TempDir(), "servers.json")}
25+
if err := store.save(cxServerFile{Servers: []cxServerConfig{{
26+
Name: "community",
27+
URL: "http://100.99.8.37:31415",
28+
}}}); err != nil {
29+
t.Fatal(err)
30+
}
31+
32+
got := defaultCodexBaseURLFor(local.URL+"/_subrouter/health", store, local.Client())
33+
if got != "http://100.99.8.37:31415/v1" {
34+
t.Fatalf("base URL = %q", got)
35+
}
36+
}
37+
38+
func TestDefaultCodexBaseURLKeepsLocalWhenLocalHealthWorks(t *testing.T) {
39+
local := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
40+
w.WriteHeader(http.StatusOK)
41+
}))
42+
t.Cleanup(local.Close)
43+
store := cxServerStore{Path: filepath.Join(t.TempDir(), "servers.json")}
44+
if err := store.save(cxServerFile{Servers: []cxServerConfig{{
45+
Name: "community",
46+
URL: "http://100.99.8.37:31415",
47+
}}}); err != nil {
48+
t.Fatal(err)
49+
}
50+
51+
got := defaultCodexBaseURLFor(local.URL+"/_subrouter/health", store, local.Client())
52+
if got != defaultCodexBaseURL {
53+
t.Fatalf("base URL = %q, want %q", got, defaultCodexBaseURL)
54+
}
55+
}
56+
57+
func TestDefaultCodexBaseURLDoesNotGuessWhenMultipleServersAreConfigured(t *testing.T) {
58+
local := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
59+
http.Error(w, "old gateway", http.StatusForbidden)
60+
}))
61+
t.Cleanup(local.Close)
62+
store := cxServerStore{Path: filepath.Join(t.TempDir(), "servers.json")}
63+
if err := store.save(cxServerFile{Servers: []cxServerConfig{
64+
{Name: "community", URL: "http://100.99.8.37:31415"},
65+
{Name: "other", URL: "http://100.99.8.38:31415"},
66+
}}); err != nil {
67+
t.Fatal(err)
68+
}
69+
70+
got := defaultCodexBaseURLFor(local.URL+"/_subrouter/health", store, local.Client())
71+
if got != defaultCodexBaseURL {
72+
t.Fatalf("base URL = %q, want %q", got, defaultCodexBaseURL)
73+
}
74+
}
75+
1676
func TestCodexArgsWorksWithoutSubcommand(t *testing.T) {
1777
got := codexArgs(nil, "http://127.0.0.1:31415/v1", "", "")
1878
want := []string{"-c", `openai_base_url="http://127.0.0.1:31415/v1"`}

cmd/subrouter/cx.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ Usage:
5555
cx server add <name> --url <url> --gcp-instance <name> --gcp-zone <zone> [--gcp-project <project>]
5656
cx server install <name>
5757
cx server login <name> [--device-auth]
58+
cx server sync <name> [--device-auth]
5859
5960
cx admin-keys List stored OpenAI admin keys
6061
cx add-admin-key Add an sk-admin-* key

0 commit comments

Comments
 (0)