Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ Resource groups offer optional session stickiness, disabled by default. When ena

Resource groups also offer account failover, enabled by default for new and existing groups and switchable per group. When enabled, an existing Responses conversation moves to another admitted account of the same provider instead of failing when its bound account has exhausted its quota or allowance, is cooling down for five minutes or more, is disabled, needs reauthorization, was deleted or left the pool; if no account can take it, the original account's error is returned. The binding records 16-byte fingerprints (at most 4,096) of every encrypted reasoning or compaction item present at the move, and those items are withheld from the new account on that and every later turn, so only readable transcript and the new account's own state reach it. Requests that reference provider-held state (`previous_response_id`, `conversation`, `container` or `item_reference`) never move, and saturation, short cooldowns or model mismatch never move a conversation. Groups with failover off keep the strict rule that conversations never switch accounts.

Each upstream account presents one client fingerprint, following CLIProxyAPI. Claude accounts keep their durable device ID and pin one device profile (User-Agent and X-Stainless OS, architecture, runtime and package versions) per account. Codex accounts pin the platform and terminal of the first native Codex client that uses them (stored on the account) and present it with SubLane's managed Codex version to every later client, whatever machine or tool it runs on; accounts never seen by a native client keep the bare version agent. Different accounts may present different fingerprints.
Each upstream account presents one client fingerprint, following CLIProxyAPI; different accounts may present different fingerprints. The profile is stored on the account (`client_profile`) and is learned from the first native client that uses it: Codex keeps that client's User-Agent platform and terminal, and Claude keeps the operating system and architecture its Claude Code client reports in X-Stainless headers. Every later client, on any machine or tool, presents the pinned values. Client versions stay under SubLane's version policy: Codex uses the managed Codex version, and Claude runtime and SDK versions follow the SDK's measured baseline for the managed Claude Code release. Claude accounts also keep one durable device ID, and the SDK's device-profile stabilization keeps the remaining Claude headers consistent per account. Administrators can view, set or clear an account's fingerprint from the account menu (validated values only; empty fields fall back to learning or defaults) and can give a Claude account a new device ID; changes are audited.

Provider requests whose connection fails before any byte is sent (DNS, TCP dial or proxy CONNECT) are retried up to twice after 250 ms and 1 s on the same account; failures after a request may have reached the provider are never replayed.

Expand Down
22 changes: 11 additions & 11 deletions internal/accounts/credential.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,17 @@ type Credential struct {
APIPlatform string `json:"api_platform,omitempty"`
APIProtocol string `json:"api_protocol,omitempty"`
ProxyURL string `json:"-"`
// ClientPlatform is the account's pinned client platform (for example "(Mac OS 15.5.0; arm64) iTerm.app/3.6.1").
ClientPlatform string `json:"-"`
Provider string `json:"provider,omitempty"`
Metadata map[string]json.RawMessage `json:"metadata,omitempty"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
IDToken string `json:"id_token,omitempty"`
AccountID string `json:"account_id"`
Email string `json:"email,omitempty"`
Plan string `json:"plan,omitempty"`
ExpiresAt int64 `json:"expires_at"`
// Profile is the account's pinned client fingerprint; it is stored on the account, not the credential.
Profile ClientProfile `json:"-"`
Provider string `json:"provider,omitempty"`
Metadata map[string]json.RawMessage `json:"metadata,omitempty"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
IDToken string `json:"id_token,omitempty"`
AccountID string `json:"account_id"`
Email string `json:"email,omitempty"`
Plan string `json:"plan,omitempty"`
ExpiresAt int64 `json:"expires_at"`
// Keep rejection with the encrypted token so a restart cannot make it eligible for fallback.
Rejected bool `json:"access_token_rejected,omitempty"`
}
Expand Down
235 changes: 235 additions & 0 deletions internal/accounts/fingerprint.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
package accounts

import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"regexp"

"github.com/murongg/SubLane/internal/audit"
"github.com/murongg/SubLane/internal/storage/db"
)

// ErrFingerprintUnsupported reports a provider without a client fingerprint (API channels and
// providers whose SDK executor sends a fixed client identity).
var ErrFingerprintUnsupported = errors.New("fingerprint_unsupported")

// ClientProfile is the client fingerprint one upstream account presents to its provider, so every
// client sharing the account looks like the same machine. Empty fields fall back to SubLane's
// managed defaults. Versions of the client itself always come from SubLane's version policy.
type ClientProfile struct {
// Source is "learned" (copied from the first native client) or "manual" (set by an administrator).
Source string `json:"source"`
// Platform is the Codex User-Agent platform, for example "(Mac OS 15.5.0; arm64) iTerm.app/3.6.1".
Platform string `json:"platform,omitempty"`
// OS, Arch, RuntimeVersion and PackageVersion are Claude's X-Stainless headers.
OS string `json:"os,omitempty"`
Arch string `json:"arch,omitempty"`
RuntimeVersion string `json:"runtime_version,omitempty"`
PackageVersion string `json:"package_version,omitempty"`
}

// Fingerprint is the administrator view of an account's client fingerprint. DeviceID is a short
// prefix of the Claude device identifier, never the full value.
type Fingerprint struct {
Provider string `json:"provider"`
Profile ClientProfile `json:"profile"`
DeviceID string `json:"device_id,omitempty"`
}

var (
// codexClientAgent matches a native Codex User-Agent:
// "<originator>/<version> (<os> <os version>; <arch>) <terminal>[ (<suffix>)]".
codexClientAgent = regexp.MustCompile(`^(?:codex_cli_rs|codex-tui|codex_exec|codex_vscode)/[0-9]+\.[0-9]+\.[0-9]+[0-9A-Za-z.+-]{0,24} (\([A-Za-z][A-Za-z0-9 ._-]{0,48} [0-9][0-9A-Za-z._-]{0,24}; [A-Za-z0-9_]{1,16}\)(?: [A-Za-z0-9][A-Za-z0-9._+-]{0,40}(?:/[A-Za-z0-9._+-]{1,40})?)?)`)
codexPlatform = regexp.MustCompile(`^\([A-Za-z][A-Za-z0-9 ._-]{0,48} [0-9][0-9A-Za-z._-]{0,24}; [A-Za-z0-9_]{1,16}\)(?: [A-Za-z0-9][A-Za-z0-9._+-]{0,40}(?:/[A-Za-z0-9._+-]{1,40})?)?$`)
claudeClient = regexp.MustCompile(`^claude-cli/[0-9]+\.[0-9]+\.[0-9]+ \(external, [a-z-]{1,32}\)$`)
// The Stainless values the Anthropic SDK reports on the platforms Claude Code runs on.
claudeOS = regexp.MustCompile(`^(MacOS|Windows|Linux|FreeBSD)$`)
claudeArch = regexp.MustCompile(`^(arm64|x64|x86|arm)$`)
claudeRuntime = regexp.MustCompile(`^v[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$`)
claudePackage = regexp.MustCompile(`^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$`)
)

// CodexClientPlatform extracts the platform portion of a native Codex User-Agent, or "".
func CodexClientPlatform(userAgent string) string {
if len(userAgent) > 512 {
return ""
}
match := codexClientAgent.FindStringSubmatch(userAgent)
if match == nil {
return ""
}
return match[1]
}

// ObservedProfile returns the fingerprint a native client of provider presents, or false. Claude
// learns only machine attributes (OS and architecture): runtime and SDK versions belong to the
// Claude Code release, which SubLane's version policy manages.
func ObservedProfile(provider string, headers http.Header) (ClientProfile, bool) {
switch provider {
case "codex":
if platform := CodexClientPlatform(headers.Get("User-Agent")); platform != "" {
return ClientProfile{Source: "learned", Platform: platform}, true
}
case "claude":
os, arch := headers.Get("X-Stainless-Os"), headers.Get("X-Stainless-Arch")
if claudeClient.MatchString(headers.Get("User-Agent")) && claudeOS.MatchString(os) && claudeArch.MatchString(arch) {
return ClientProfile{Source: "learned", OS: os, Arch: arch}, true
}
}
return ClientProfile{}, false
}

func (p ClientProfile) valid(provider string) bool {
switch provider {
case "codex":
return p.OS == "" && p.Arch == "" && p.RuntimeVersion == "" && p.PackageVersion == "" && (p.Platform == "" || len(p.Platform) <= 160 && codexPlatform.MatchString(p.Platform))
case "claude":
return p.Platform == "" && (p.OS == "" || claudeOS.MatchString(p.OS)) && (p.Arch == "" || claudeArch.MatchString(p.Arch)) &&
(p.RuntimeVersion == "" || claudeRuntime.MatchString(p.RuntimeVersion)) && (p.PackageVersion == "" || claudePackage.MatchString(p.PackageVersion))
}
return false
}

func (p ClientProfile) empty() bool {
return p.Platform == "" && p.OS == "" && p.Arch == "" && p.RuntimeVersion == "" && p.PackageVersion == ""
}

func fingerprintProvider(provider string) bool { return provider == "codex" || provider == "claude" }

func decodeProfile(raw *string) ClientProfile {
var profile ClientProfile
if raw != nil {
_ = json.Unmarshal([]byte(*raw), &profile)
}
return profile
}

// LearnClientProfile pins an account's fingerprint from its first native client. Later clients
// and an existing manual profile are never replaced.
func (s *Service) LearnClientProfile(ctx context.Context, id string, profile ClientProfile) error {
row, err := s.get(ctx, id)
if err != nil || row.ClientProfile != nil || !profile.valid(row.Provider) || profile.empty() {
return err
}
raw, err := json.Marshal(profile)
if err != nil {
return err
}
encoded := string(raw)
_, err = s.queries.LearnClientProfile(ctx, db.LearnClientProfileParams{ID: id, TenantID: s.tenantID, ClientProfile: &encoded})
return err
}

// Fingerprint returns an account's current client fingerprint.
func (s *Service) Fingerprint(ctx context.Context, id string) (Fingerprint, error) {
s.mu.Lock()
defer s.mu.Unlock()
row, err := s.get(ctx, id)
if err != nil {
return Fingerprint{}, err
}
if !fingerprintProvider(row.Provider) {
return Fingerprint{}, ErrFingerprintUnsupported
}
result := Fingerprint{Provider: row.Provider, Profile: decodeProfile(row.ClientProfile)}
if row.Provider == "claude" {
credential, err := s.decrypt(row)
if err != nil {
return Fingerprint{}, err
}
if device := claudeDeviceID(credential.Metadata); device != "" {
result.DeviceID = device[:8]
}
}
return result, nil
}

// SetFingerprint stores an administrator's fingerprint. An empty profile clears it, so the next
// native client is learned again.
func (s *Service) SetFingerprint(ctx context.Context, id string, profile ClientProfile) (Fingerprint, error) {
return s.changeFingerprint(ctx, id, &profile, false, "account.fingerprint")
}

// ResetFingerprint clears the profile; with device it also gives a Claude account a new device ID,
// which the provider sees as a new machine.
func (s *Service) ResetFingerprint(ctx context.Context, id string, device bool) (Fingerprint, error) {
return s.changeFingerprint(ctx, id, nil, device, "account.fingerprint_reset")
}

func (s *Service) changeFingerprint(ctx context.Context, id string, profile *ClientProfile, device bool, action string) (Fingerprint, error) {
s.mu.Lock()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
s.mu.Unlock()
return Fingerprint{}, err
}
defer tx.Rollback()
q := s.queries.WithTx(tx)
row, err := q.GetAccount(ctx, db.GetAccountParams{ID: id, TenantID: s.tenantID})
if errors.Is(err, sql.ErrNoRows) {
s.mu.Unlock()
return Fingerprint{}, ErrNotFound
}
if err != nil {
s.mu.Unlock()
return Fingerprint{}, err
}
if !fingerprintProvider(row.Provider) || device && row.Provider != "claude" {
s.mu.Unlock()
return Fingerprint{}, ErrFingerprintUnsupported
}
var stored *string
if profile != nil && !profile.empty() {
profile.Source = "manual"
if !profile.valid(row.Provider) {
s.mu.Unlock()
return Fingerprint{}, ErrInput
}
raw, err := json.Marshal(profile)
if err != nil {
s.mu.Unlock()
return Fingerprint{}, err
}
encoded := string(raw)
stored = &encoded
}
if _, err := q.SetClientProfile(ctx, db.SetClientProfileParams{ID: id, TenantID: s.tenantID, ClientProfile: stored, UpdatedAt: s.now().Unix()}); err != nil {
s.mu.Unlock()
return Fingerprint{}, err
}
if device {
credential, err := s.decrypt(row)
if err != nil {
s.mu.Unlock()
return Fingerprint{}, err
}
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
s.mu.Unlock()
return Fingerprint{}, err
}
if credential, _, err = prepareClaudeIdentity(credential, hex.EncodeToString(raw)); err != nil {
s.mu.Unlock()
return Fingerprint{}, err
}
if err := s.persist(ctx, q, id, credential, row.Status); err != nil {
s.mu.Unlock()
return Fingerprint{}, err
}
}
if err := audit.Record(ctx, q, action, "account", id); err != nil {
s.mu.Unlock()
return Fingerprint{}, err
}
if err := tx.Commit(); err != nil {
s.mu.Unlock()
return Fingerprint{}, err
}
s.mu.Unlock()
return s.Fingerprint(ctx, id)
}
96 changes: 96 additions & 0 deletions internal/accounts/fingerprint_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package accounts

import (
"context"
"errors"
"net/http"
"testing"
"time"
)

func TestObservedProfileKeepsOnlyNativeMachineAttributes(t *testing.T) {
for _, test := range []struct {
provider string
headers http.Header
want ClientProfile
}{
{"codex", http.Header{"User-Agent": {"codex_cli_rs/0.46.0 (Mac OS 15.5.0; arm64) iTerm.app/3.6.1"}}, ClientProfile{Source: "learned", Platform: "(Mac OS 15.5.0; arm64) iTerm.app/3.6.1"}},
{"codex", http.Header{"User-Agent": {"codex-tui/0.154.0 (Windows 10.0.26100; x86_64) WindowsTerminal (codex-tui)"}}, ClientProfile{Source: "learned", Platform: "(Windows 10.0.26100; x86_64) WindowsTerminal"}},
{"codex", http.Header{"User-Agent": {"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"}}, ClientProfile{}},
{"claude", http.Header{"User-Agent": {"claude-cli/2.1.287 (external, cli)"}, "X-Stainless-Os": {"Windows"}, "X-Stainless-Arch": {"x64"}, "X-Stainless-Runtime-Version": {"v22.1.0"}}, ClientProfile{Source: "learned", OS: "Windows", Arch: "x64"}},
{"claude", http.Header{"User-Agent": {"SyntheticClient/1.0"}, "X-Stainless-Os": {"Windows"}, "X-Stainless-Arch": {"x64"}}, ClientProfile{}},
{"claude", http.Header{"User-Agent": {"claude-cli/2.1.287 (external, cli)"}, "X-Stainless-Os": {"Plan9"}, "X-Stainless-Arch": {"x64"}}, ClientProfile{}},
} {
got, _ := ObservedProfile(test.provider, test.headers)
if got != test.want {
t.Errorf("%s %v: got %+v, want %+v", test.provider, test.headers, got, test.want)
}
}
}

func TestLearnedProfileIsPinnedAndReachesExecution(t *testing.T) {
s, account, _ := refreshFixture(t, "codex", time.Hour)
ctx := context.Background()
if err := s.LearnClientProfile(ctx, account.ID, ClientProfile{Source: "learned", Platform: "(Mac OS 15.5.0; arm64) iTerm.app/3.6.1"}); err != nil {
t.Fatal(err)
}
// A later client on another machine must not replace the account's fingerprint.
if err := s.LearnClientProfile(ctx, account.ID, ClientProfile{Source: "learned", Platform: "(Windows 10.0.26100; x86_64) WindowsTerminal"}); err != nil {
t.Fatal(err)
}
credential, err := s.Prepare(ctx, account.ID, nil)
if err != nil || credential.Profile.Platform != "(Mac OS 15.5.0; arm64) iTerm.app/3.6.1" {
t.Fatalf("pinned platform missing from execution credential: %+v %v", credential.Profile, err)
}
}

func TestManualFingerprintValidatesAndResetRelearns(t *testing.T) {
s, account, _ := refreshFixture(t, "claude", time.Hour)
ctx := context.Background()
if _, err := s.Prepare(ctx, account.ID, nil); err != nil {
t.Fatal(err)
}
before, err := s.Fingerprint(ctx, account.ID)
if err != nil || before.Provider != "claude" || len(before.DeviceID) != 8 || before.Profile.Source != "" {
t.Fatalf("initial fingerprint: %+v %v", before, err)
}
if _, err := s.SetFingerprint(ctx, account.ID, ClientProfile{OS: "Plan9"}); !errors.Is(err, ErrInput) {
t.Fatal("invalid OS accepted", err)
}
if _, err := s.SetFingerprint(ctx, account.ID, ClientProfile{Platform: "(Mac OS 15.5.0; arm64)"}); !errors.Is(err, ErrInput) {
t.Fatal("Codex field accepted for Claude", err)
}
manual, err := s.SetFingerprint(ctx, account.ID, ClientProfile{OS: "Linux", Arch: "x64", RuntimeVersion: "v22.1.0"})
if err != nil || manual.Profile.Source != "manual" || manual.Profile.OS != "Linux" || manual.Profile.RuntimeVersion != "v22.1.0" {
t.Fatalf("manual fingerprint: %+v %v", manual, err)
}
// A manual profile is never overwritten by a learned one.
if err := s.LearnClientProfile(ctx, account.ID, ClientProfile{Source: "learned", OS: "Windows", Arch: "x64"}); err != nil {
t.Fatal(err)
}
credential, err := s.Prepare(ctx, account.ID, nil)
if err != nil || credential.Profile.OS != "Linux" {
t.Fatalf("manual fingerprint did not reach execution: %+v %v", credential.Profile, err)
}
reset, err := s.ResetFingerprint(ctx, account.ID, true)
if err != nil || reset.Profile != (ClientProfile{}) || reset.DeviceID == before.DeviceID || len(reset.DeviceID) != 8 {
t.Fatalf("reset kept fingerprint or device: %+v %v", reset, err)
}
if err := s.LearnClientProfile(ctx, account.ID, ClientProfile{Source: "learned", OS: "Windows", Arch: "x64"}); err != nil {
t.Fatal(err)
}
if relearned, err := s.Fingerprint(ctx, account.ID); err != nil || relearned.Profile.OS != "Windows" || relearned.Profile.Source != "learned" {
t.Fatalf("reset account did not learn again: %+v %v", relearned, err)
}
}

func TestFingerprintRejectsProvidersWithoutOne(t *testing.T) {
s, account, _ := refreshFixture(t, "antigravity", time.Hour)
if _, err := s.Fingerprint(context.Background(), account.ID); !errors.Is(err, ErrFingerprintUnsupported) {
t.Fatal(err)
}
codex, codexAccount, _ := refreshFixture(t, "codex", time.Hour)
if _, err := codex.ResetFingerprint(context.Background(), codexAccount.ID, true); !errors.Is(err, ErrFingerprintUnsupported) {
t.Fatal("Codex accepted a device reset", err)
}
}
Loading
Loading