Skip to content
14 changes: 7 additions & 7 deletions cmd/subrouter/codex.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,17 +51,13 @@ func codex(args []string) error {
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
env := os.Environ()
if localProxyToken != "" {
env = upsertEnv(
env,
cmd.Env = upsertEnv(
os.Environ(),
"SUBROUTER_CODEX_DUMMY_API_KEY",
localProxyToken,
)
} else if userEmail != "" || accountID != "" || codexModelArg(args) != "" {
env = upsertEnv(env, "SUBROUTER_CODEX_DUMMY_API_KEY", "subrouter")
}
cmd.Env = env
return cmd.Run()
}

Expand Down Expand Up @@ -240,11 +236,15 @@ func codexConfigArgs(
model == "" {
return []string{"-c", "openai_base_url=" + strconv.Quote(baseURL)}
}
authConfig := `model_providers.subrouter.experimental_bearer_token="subrouter"`
if forceAuthenticatedProvider {
authConfig = `model_providers.subrouter.env_key="SUBROUTER_CODEX_DUMMY_API_KEY"`
}
return []string{
"-c", `model_provider="subrouter"`,
"-c", `model_providers.subrouter.name="Subrouter"`,
"-c", "model_providers.subrouter.base_url=" + strconv.Quote(baseURL),
"-c", `model_providers.subrouter.env_key="SUBROUTER_CODEX_DUMMY_API_KEY"`,
"-c", authConfig,
"-c", `model_providers.subrouter.wire_api="responses"`,
"-c", `model_providers.subrouter.supports_websockets=true`,
"-c", `model_providers.subrouter.http_headers=` + codexSubrouterHeaders(userEmail, accountID, model),
Expand Down
15 changes: 13 additions & 2 deletions cmd/subrouter/codex_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ func TestCodexArgsInjectsUserEmailWithCustomSubrouterProvider(t *testing.T) {
"-c", `model_provider="subrouter"`,
"-c", `model_providers.subrouter.name="Subrouter"`,
"-c", `model_providers.subrouter.base_url="http://127.0.0.1:31415/v1"`,
"-c", `model_providers.subrouter.env_key="SUBROUTER_CODEX_DUMMY_API_KEY"`,
"-c", `model_providers.subrouter.experimental_bearer_token="subrouter"`,
"-c", `model_providers.subrouter.wire_api="responses"`,
"-c", `model_providers.subrouter.supports_websockets=true`,
"-c", `model_providers.subrouter.http_headers={"X-Subrouter-Agent"="codex","X-Subrouter-User-Email"="alice@example.com"}`,
Expand All @@ -162,7 +162,7 @@ func TestCodexArgsInjectsAccountIDWithCustomSubrouterProvider(t *testing.T) {
"-c", `model_provider="subrouter"`,
"-c", `model_providers.subrouter.name="Subrouter"`,
"-c", `model_providers.subrouter.base_url="http://127.0.0.1:31415/v1"`,
"-c", `model_providers.subrouter.env_key="SUBROUTER_CODEX_DUMMY_API_KEY"`,
"-c", `model_providers.subrouter.experimental_bearer_token="subrouter"`,
"-c", `model_providers.subrouter.wire_api="responses"`,
"-c", `model_providers.subrouter.supports_websockets=true`,
"-c", `model_providers.subrouter.http_headers={"X-Subrouter-Agent"="codex","X-Subrouter-Account-ID"="team-codex-1"}`,
Expand All @@ -173,6 +173,17 @@ func TestCodexArgsInjectsAccountIDWithCustomSubrouterProvider(t *testing.T) {
}
}

func TestCodexArgsKeepsCustomProviderAuthInResumableArguments(t *testing.T) {
got := codexArgs([]string{"exec", "prompt"}, "http://127.0.0.1:31415/v1", "", "team-codex-1")
joined := strings.Join(got, "\n")
if strings.Contains(joined, `env_key="SUBROUTER_CODEX_DUMMY_API_KEY"`) {
t.Fatalf("args depend on process-only environment and will fail when Codex is resumed directly:\n%s", joined)
}
if !strings.Contains(joined, `experimental_bearer_token="subrouter"`) {
t.Fatalf("args lack self-contained provider authentication:\n%s", joined)
}
}

func TestCodexArgsInjectsUserEmailAndAccountID(t *testing.T) {
got := codexArgs([]string{"exec", "prompt"}, "http://127.0.0.1:31415/v1", "alice@example.com", "apikey:paid")
headers := `model_providers.subrouter.http_headers={"X-Subrouter-Agent"="codex","X-Subrouter-User-Email"="alice@example.com","X-Subrouter-Account-ID"="apikey:paid"}`
Expand Down
86 changes: 85 additions & 1 deletion cmd/subrouter/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
agentclaude "github.com/manaflow-ai/subrouter/internal/agents/claude"
"github.com/manaflow-ai/subrouter/internal/broker"
"github.com/manaflow-ai/subrouter/internal/proxy"
"github.com/manaflow-ai/subrouter/internal/stackauth"
"github.com/manaflow-ai/subrouter/internal/storepath"
"github.com/manaflow-ai/subrouter/internal/tenant"
"github.com/manaflow-ai/subrouter/internal/transcript"
Expand Down Expand Up @@ -160,6 +161,8 @@ var directSRCommands = map[string]struct{}{
"pick": {},
"remove": {},
"remove-admin-key": {},
"remote": {},
"remotes": {},
"reset": {},
"rm": {},
"server": {},
Expand Down Expand Up @@ -209,6 +212,11 @@ func serve(args []string) error {
maxBodyBytes := flags.Int64("max-body-bytes", 1<<20, "max JSON request body bytes to inspect for session IDs")
fetchUsage := flags.Bool("fetch-usage", true, "fetch Codex usage on startup for account selection")
multiTenant := flags.Bool("multi-tenant", false, "reject unknown srt_ tenant keys even before the first tenant exists; tenant routing itself activates automatically once tenants exist")
publicURL := flags.String("public-url", "", "public Subrouter origin used in hosted tenant responses; defaults to SUBROUTER_PUBLIC_URL")
stackAPIURL := flags.String("stack-api-url", "", "Stack Auth API base URL; defaults to SUBROUTER_STACK_API_URL")
stackProjectID := flags.String("stack-project-id", "", "Stack Auth project ID enabling hosted login; defaults to SUBROUTER_STACK_PROJECT_ID")
stackPublishableClientKey := flags.String("stack-publishable-client-key", "", "Stack Auth publishable client key; defaults to SUBROUTER_STACK_PUBLISHABLE_CLIENT_KEY")
stackTenantKeySecret := flags.String("stack-tenant-key-secret", "", "local-development override for stable Stack-team tenant keys; deployments use SUBROUTER_STACK_TENANT_KEY_SECRET")
bedrockEnable := flags.Bool("bedrock", false, "enable the /bedrock/* AWS SigV4 signing gateway for Claude Code Bedrock mode")
bedrockRegion := flags.String("bedrock-region", "us-east-1", "comma-separated AWS regions for the Bedrock signing gateway")
bedrockGatewayToken := flags.String("bedrock-gateway-token", "", "optional bearer token clients must present to the Bedrock gateway; defaults to SUBROUTER_BEDROCK_GATEWAY_TOKEN")
Expand All @@ -229,6 +237,40 @@ func serve(args []string) error {
if *adminToken == "" {
*adminToken = strings.TrimSpace(os.Getenv("SUBROUTER_ADMIN_TOKEN"))
}
if *publicURL == "" {
*publicURL = strings.TrimSpace(os.Getenv("SUBROUTER_PUBLIC_URL"))
}
if *stackAPIURL == "" {
*stackAPIURL = strings.TrimSpace(os.Getenv("SUBROUTER_STACK_API_URL"))
}
if *stackAPIURL == "" {
*stackAPIURL = stackauth.DefaultAPIURL
}
if *stackProjectID == "" {
*stackProjectID = strings.TrimSpace(os.Getenv("SUBROUTER_STACK_PROJECT_ID"))
}
if *stackPublishableClientKey == "" {
*stackPublishableClientKey = strings.TrimSpace(os.Getenv("SUBROUTER_STACK_PUBLISHABLE_CLIENT_KEY"))
}
if *stackTenantKeySecret == "" {
*stackTenantKeySecret = strings.TrimSpace(os.Getenv("SUBROUTER_STACK_TENANT_KEY_SECRET"))
}
stackLoginValues := []string{*stackProjectID, *stackPublishableClientKey, *stackTenantKeySecret}
stackLoginConfigured := 0
for _, value := range stackLoginValues {
if value != "" {
stackLoginConfigured++
}
}
if stackLoginConfigured != 0 && stackLoginConfigured != len(stackLoginValues) {
return errors.New("hosted Stack login requires all of --stack-project-id, --stack-publishable-client-key, and --stack-tenant-key-secret (or SUBROUTER_STACK_PROJECT_ID, SUBROUTER_STACK_PUBLISHABLE_CLIENT_KEY, and SUBROUTER_STACK_TENANT_KEY_SECRET)")
}
if *stackTenantKeySecret != "" && len(*stackTenantKeySecret) < 32 {
return errors.New("--stack-tenant-key-secret or SUBROUTER_STACK_TENANT_KEY_SECRET must be at least 32 bytes")
}
if err := validatePublicSubrouterURL(*publicURL); err != nil {
return err
}

var upstream *url.URL
if *upstreamRaw != "" {
Expand Down Expand Up @@ -424,6 +466,20 @@ func serve(args []string) error {
Registry: tenant.NewRegistry(storepath.StateDir()),
TranscriptDir: *transcriptDir,
Enabled: *multiTenant,
PublicURL: strings.TrimRight(*publicURL, "/"),
}
if *stackProjectID != "" {
stackHTTPClient := &http.Client{Timeout: 15 * time.Second}
multiTenantHandler.StackVerifier = &stackauth.Verifier{
APIURL: *stackAPIURL, ProjectID: *stackProjectID,
HTTPClient: stackHTTPClient,
}
multiTenantHandler.StackTeams = &stackauth.Client{
APIURL: *stackAPIURL, ProjectID: *stackProjectID,
PublishableClientKey: *stackPublishableClientKey,
HTTPClient: stackHTTPClient,
}
multiTenantHandler.StackTenantKeySecret = []byte(*stackTenantKeySecret)
}
httpServer := &http.Server{
Addr: *addr,
Expand All @@ -450,6 +506,27 @@ func serve(args []string) error {
return listenAndServeWithSignals(httpServer, server.Lifecycle, *shutdownTimeout, slog.Default())
}

func validatePublicSubrouterURL(raw string) error {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
parsed, err := url.Parse(raw)
if err != nil || parsed.Host == "" || parsed.User != nil ||
(parsed.Path != "" && parsed.Path != "/") ||
parsed.RawQuery != "" || parsed.Fragment != "" {
return errors.New("--public-url or SUBROUTER_PUBLIC_URL must be an origin such as https://sr.example.com")
}
host := strings.ToLower(parsed.Hostname())
ip := net.ParseIP(host)
loopback := host == "localhost" || (ip != nil && ip.IsLoopback())
if parsed.Scheme != "https" &&
!(parsed.Scheme == "http" && loopback) {
return errors.New("--public-url or SUBROUTER_PUBLIC_URL must use HTTPS, except on loopback")
}
return nil
}

func loadProxyAccounts(
ctx context.Context,
teamMode bool,
Expand Down Expand Up @@ -983,7 +1060,7 @@ Getting started:

Credential storage:
%[1]s storage Show the active credential source
%[1]s storage team Use credentials shared with the selected Stack team
%[1]s storage hosted Use credentials hosted for the selected Stack team
%[1]s storage local Keep and use credentials only on this machine
%[1]s storage legacy Use the selected legacy remote Subrouter server

Expand Down Expand Up @@ -1024,6 +1101,13 @@ Usage:
%[1]s usage [days] Refresh and show API-key spend
%[1]s trace <email> Show OAuth refresh breadcrumbs for an account

%[1]s remote -v List local, cmux hosted, and self-hosted remotes
%[1]s remote use local Route agents through this computer
%[1]s remote use cmux Route agents through hosted cmux
%[1]s remote add <name> <url>
Add a self-hosted Subrouter
%[1]s remote use <name> Route agents through a self-hosted Subrouter

%[1]s daemon start Start this machine's local proxy
%[1]s daemon stop Stop this machine's local proxy
%[1]s daemon restart Restart this machine's local proxy
Expand Down
25 changes: 25 additions & 0 deletions cmd/subrouter/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,29 @@ func TestConfigureDefaultLoggerLeavesSupervisorLoggerAlone(t *testing.T) {
}
}

func TestValidatePublicSubrouterURLRequiresAnHTTPSOrigin(t *testing.T) {
for _, valid := range []string{
"",
"https://sr.example.com",
"https://sr.example.com/",
"http://127.0.0.1:31415",
} {
if err := validatePublicSubrouterURL(valid); err != nil {
t.Fatalf("%q: %v", valid, err)
}
}
for _, invalid := range []string{
"https://sr.example.com/path",
"https://user@sr.example.com",
"https://sr.example.com?query=1",
"http://sr.example.com",
} {
if err := validatePublicSubrouterURL(invalid); err == nil {
t.Fatalf("%q was accepted", invalid)
}
}
}

func TestSystemdListenFDsParsesCurrentProcess(t *testing.T) {
env := map[string]string{
"LISTEN_PID": "123",
Expand Down Expand Up @@ -241,6 +264,8 @@ func TestDirectSRCommandNames(t *testing.T) {
"logout",
"ls",
"pick",
"remote",
"remotes",
"remove",
"remove-admin-key",
"reset",
Expand Down
42 changes: 32 additions & 10 deletions cmd/subrouter/sr.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@ const srHelp = `sr - Manage Subrouter accounts

Usage:
sr Show Codex and Claude usage, grouped by provider
sr add Add a new Codex account (opens OAuth login)
sr add Ask whether to add Codex or Claude
sr add codex Add Codex to the active local or hosted pool
sr add claude Add Claude to the active local or hosted pool
sr add-key Add an API key account
sr import Import current ~/.codex/auth.json account
sr list List all Codex accounts
Expand All @@ -63,13 +65,23 @@ Usage:
sr trace <email> Show OAuth refresh breadcrumbs for an account

Getting started:
sr setup Log in, choose a team, install the daemon, and verify it
sr setup --storage local
Install the daemon with credentials kept on this machine
sr login Authenticate with cmux.com through Stack Auth
sr add codex Add a Codex account to hosted cmux
sr add claude Add a Claude account to hosted cmux
sr logout Revoke this machine's cmux.com session
sr remote -v List local, cmux hosted, and self-hosted remotes
sr remote use local Route agents through this computer
sr remote use cmux Route agents through hosted cmux
sr remote add <name> <url>
Add a self-hosted Subrouter
sr remote use <name> Route agents through a self-hosted Subrouter

Advanced setup:
sr setup Install the local daemon and verify it
sr setup --storage local
Install the daemon with credentials kept on this machine
sr storage Show the active credential source
sr storage team Use credentials shared with the selected Stack team
sr storage hosted Use credentials hosted for the selected Stack team
sr storage local Keep and use credentials only on this machine
sr storage legacy Use the selected legacy remote Subrouter server
sr team list List available Stack teams
Expand All @@ -89,7 +101,7 @@ Running agents:
sr claude [args] Run claude through Subrouter
sr gemini [args] Run gemini through Subrouter

sr server Manage Subrouter servers
sr server Legacy form of sr remote
sr server add <name> --url <url> [--default]
sr server use <name|local> [--no-codex-config]
sr server rename <old> <new>
Expand Down Expand Up @@ -201,7 +213,7 @@ func (r srRunner) run(ctx context.Context, args []string) error {
if len(args) == 0 {
return r.defaultInteractive(ctx, srSwitchOptions{})
}
if source == broker.CredentialSourceTeam {
if source == broker.CredentialSourceTeam || source == broker.CredentialSourceHosted {
if handled, err := r.runTeamCredentialCommand(ctx, args); handled {
return err
}
Expand Down Expand Up @@ -294,6 +306,8 @@ func (r srRunner) run(ctx context.Context, args []string) error {
return r.attachProject(ctx, args[1], projectID)
case "server", "servers":
return r.server(ctx, args[1:])
case "remote", "remotes":
return r.remote(ctx, args[1:])
case "tenant", "tenants":
return r.tenant(ctx, args[1:])
case "help", "-h", "--help":
Expand Down Expand Up @@ -323,7 +337,7 @@ func (r srRunner) run(ctx context.Context, args []string) error {

func shouldRouteSRCommand(command string) bool {
switch command {
case "server", "servers", "tenant", "tenants", "claude", "claude-aws", "claude-direct", "spend", "cost", "gemini", "help", "-h", "--help":
case "server", "servers", "remote", "remotes", "tenant", "tenants", "claude", "claude-aws", "claude-direct", "spend", "cost", "gemini", "help", "-h", "--help":
return false
// Setup, cleanup and doctor act on this machine, never the remote server.
case "setup", "cleanup", "daemon", "doctor", "login", "logout", "team", "account", "accounts", "storage":
Expand All @@ -345,7 +359,15 @@ func (r srRunner) runTeamCredentialCommand(
if err != nil {
return true, err
}
return true, r.cloudAccountAdd(ctx, client, []string{"codex"})
providerArgs := args[1:]
if len(providerArgs) == 0 {
chosen, chooseErr := r.promptProvider()
if chooseErr != nil {
return true, chooseErr
}
providerArgs = []string{chosen}
}
return true, r.cloudAccountAdd(ctx, client, providerArgs)
case "add-key", "add-api-key":
_, _, client, err := loadCloudClient(true)
if err != nil {
Expand All @@ -362,7 +384,7 @@ func (r srRunner) runTeamCredentialCommand(
return true, r.cloudAccount(ctx, args)
case "switch", "use", "g", "gui", "gui-switch", "gui-use", "pick", "reset":
return true, fmt.Errorf(
"team storage selects an account per request; use 'sr account list' or switch to local storage with 'sr storage local'",
"hosted cmux selects an account per request; use 'sr account list' or switch with 'sr remote use local'",
)
default:
return false, nil
Expand Down
Loading