From 7abeffbc58996ad0c37ad3a0661e48bd8abdf653 Mon Sep 17 00:00:00 2001 From: Alumos <1281610535@qq.com> Date: Thu, 12 Nov 2026 21:47:59 +0800 Subject: [PATCH 1/2] feat(accounts): client fingerprint management Accounts keep one structured client profile (migration 038 replaces the Codex-only column): Codex learns its native client's platform, Claude learns the operating system and architecture of its first Claude Code client and applies them as X-Stainless headers. Administrators can view, set or clear a fingerprint and give a Claude account a new device ID from the account menu; changes are validated and audited. Co-Authored-By: Claude Opus 5.5 --- PRODUCT.md | 2 +- internal/accounts/credential.go | 22 +- internal/accounts/fingerprint.go | 235 ++++++++++++++ internal/accounts/fingerprint_test.go | 96 ++++++ internal/accounts/platform.go | 35 -- internal/accounts/platform_test.go | 38 --- internal/accounts/refresh.go | 6 +- internal/audit/audit.go | 1 + internal/backup/backup_test.go | 2 +- internal/gateway/gateway.go | 10 +- internal/server/accounts.go | 48 +++ internal/server/audit.go | 6 + internal/storage/db/accounts.sql.go | 49 ++- internal/storage/db/models.go | 2 +- .../storage/migrations/038_client_profile.sql | 5 + internal/storage/queries/accounts.sql | 12 +- internal/upstream/channel.go | 2 +- internal/upstream/client.go | 2 +- internal/upstream/engine.go | 20 ++ internal/upstream/fingerprint_test.go | 15 + .../components/AccountFingerprint.test.tsx | 107 ++++++ web/src/components/AccountFingerprint.tsx | 304 ++++++++++++++++++ web/src/lib/accounts.ts | 43 +++ web/src/lib/audit.ts | 2 + web/src/locales/ar.json | 22 ++ web/src/locales/de.json | 22 ++ web/src/locales/en.ts | 26 ++ web/src/locales/es.json | 22 ++ web/src/locales/fr.json | 22 ++ web/src/locales/ja.json | 22 ++ web/src/locales/ko.json | 22 ++ web/src/locales/pt.json | 22 ++ web/src/locales/ru.json | 22 ++ web/src/locales/zh-TW.json | 22 ++ web/src/locales/zh.ts | 25 ++ web/src/pages/Accounts.tsx | 21 ++ 36 files changed, 1219 insertions(+), 115 deletions(-) create mode 100644 internal/accounts/fingerprint.go create mode 100644 internal/accounts/fingerprint_test.go delete mode 100644 internal/accounts/platform.go delete mode 100644 internal/accounts/platform_test.go create mode 100644 internal/storage/migrations/038_client_profile.sql create mode 100644 web/src/components/AccountFingerprint.test.tsx create mode 100644 web/src/components/AccountFingerprint.tsx diff --git a/PRODUCT.md b/PRODUCT.md index 3483d90b..2bdd55a1 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -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. diff --git a/internal/accounts/credential.go b/internal/accounts/credential.go index 7d0e1a9f..ae5aa02a 100644 --- a/internal/accounts/credential.go +++ b/internal/accounts/credential.go @@ -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"` } diff --git a/internal/accounts/fingerprint.go b/internal/accounts/fingerprint.go new file mode 100644 index 00000000..c3d35f0d --- /dev/null +++ b/internal/accounts/fingerprint.go @@ -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: + // "/ ( ; ) [ ()]". + 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) +} diff --git a/internal/accounts/fingerprint_test.go b/internal/accounts/fingerprint_test.go new file mode 100644 index 00000000..c1fe7207 --- /dev/null +++ b/internal/accounts/fingerprint_test.go @@ -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) + } +} diff --git a/internal/accounts/platform.go b/internal/accounts/platform.go deleted file mode 100644 index c688232b..00000000 --- a/internal/accounts/platform.go +++ /dev/null @@ -1,35 +0,0 @@ -package accounts - -import ( - "context" - "regexp" - - "github.com/murongg/SubLane/internal/storage/db" -) - -// codexClientAgent matches a native Codex User-Agent: -// "/ ( ; ) [ ()]". -// Only the platform and terminal are kept; the version always comes from SubLane's version policy. -var 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})?)?)`) - -// 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] -} - -// LearnClientPlatform pins an account's client platform from its first native client. Later -// observations never replace it, so every client sharing the account presents one fingerprint. -func (s *Service) LearnClientPlatform(ctx context.Context, id, platform string) error { - if platform == "" || len(platform) > 160 { - return nil - } - _, err := s.queries.LearnClientPlatform(ctx, db.LearnClientPlatformParams{ID: id, TenantID: s.tenantID, ClientPlatform: &platform}) - return err -} diff --git a/internal/accounts/platform_test.go b/internal/accounts/platform_test.go deleted file mode 100644 index 09f599d9..00000000 --- a/internal/accounts/platform_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package accounts - -import ( - "context" - "testing" - "time" -) - -func TestCodexClientPlatformKeepsOnlyNativePlatform(t *testing.T) { - for agent, want := range map[string]string{ - "codex_cli_rs/0.46.0 (Mac OS 15.5.0; arm64) iTerm.app/3.6.1": "(Mac OS 15.5.0; arm64) iTerm.app/3.6.1", - "codex-tui/0.154.0 (Windows 10.0.26100; x86_64) WindowsTerminal (codex-tui)": "(Windows 10.0.26100; x86_64) WindowsTerminal", - "codex_exec/0.50.0 (Ubuntu 24.04; x86_64)": "(Ubuntu 24.04; x86_64)", - "claude-cli/2.1.287 (external, cli)": "", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)": "", - "codex_cli_rs/0.46.0": "", - } { - if got := CodexClientPlatform(agent); got != want { - t.Errorf("%q: got %q, want %q", agent, got, want) - } - } -} - -func TestLearnedClientPlatformIsPinnedAndReachesExecution(t *testing.T) { - s, account, _ := refreshFixture(t, "codex", time.Hour) - ctx := context.Background() - if err := s.LearnClientPlatform(ctx, account.ID, "(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.LearnClientPlatform(ctx, account.ID, "(Windows 10.0.26100; x86_64) WindowsTerminal"); err != nil { - t.Fatal(err) - } - credential, err := s.Prepare(ctx, account.ID, nil) - if err != nil || credential.ClientPlatform != "(Mac OS 15.5.0; arm64) iTerm.app/3.6.1" { - t.Fatalf("pinned platform missing from execution credential: %q %v", credential.ClientPlatform, err) - } -} diff --git a/internal/accounts/refresh.go b/internal/accounts/refresh.go index ea48aca5..8ed6b71a 100644 --- a/internal/accounts/refresh.go +++ b/internal/accounts/refresh.go @@ -73,9 +73,7 @@ func (s *Service) prepare(ctx context.Context, id, rejectedToken string, refresh return Credential{}, err } } - if row.ClientPlatform != nil { - credential.ClientPlatform = *row.ClientPlatform - } + credential.Profile = decodeProfile(row.ClientProfile) if row.ProxyID != nil { credential.ProxyURL, err = s.proxyURL(ctx, *row.ProxyID) if err != nil { @@ -145,7 +143,7 @@ func (s *Service) prepare(ctx context.Context, id, rejectedToken string, refresh return Credential{}, err } delete(s.refreshes, id) - updated.ProxyURL, updated.ClientPlatform = credential.ProxyURL, credential.ClientPlatform + updated.ProxyURL, updated.Profile = credential.ProxyURL, credential.Profile return updated, nil } diff --git a/internal/audit/audit.go b/internal/audit/audit.go index 3ea2ec84..f5933dee 100644 --- a/internal/audit/audit.go +++ b/internal/audit/audit.go @@ -81,6 +81,7 @@ var actions = map[string]string{ "account.create": "account", "account.authorize": "account", "account.update": "account", "account.delete": "account", "account.concurrency": "account", "account.resume": "account", "account.proxy": "account", "proxy.create": "proxy", "proxy.update": "proxy", "proxy.delete": "proxy", "proxy.import": "proxy", "proxy.prune": "proxy", "account.reset_settings": "account", "account.reset_use": "account", + "account.fingerprint": "account", "account.fingerprint_reset": "account", } var numericID = regexp.MustCompile(`^[1-9][0-9]{0,18}$`) var accountID = regexp.MustCompile(`^[a-f0-9]{32}$`) diff --git a/internal/backup/backup_test.go b/internal/backup/backup_test.go index a425da5f..d93b1efc 100644 --- a/internal/backup/backup_test.go +++ b/internal/backup/backup_test.go @@ -212,7 +212,7 @@ ALTER TABLE accounts DROP COLUMN reset_check_at; ALTER TABLE accounts DROP COLUMN usage_revision; ALTER TABLE account_affinity DROP COLUMN rebound; ALTER TABLE account_affinity DROP COLUMN model; -ALTER TABLE accounts DROP COLUMN client_platform; +ALTER TABLE accounts DROP COLUMN client_profile; ALTER TABLE account_groups DROP COLUMN quota_failover; ALTER TABLE account_affinity DROP COLUMN foreign_state; DROP INDEX account_groups_active_name; diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 7970c03e..e64bdfd7 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -236,12 +236,10 @@ func (s *Service) Open(ctx context.Context, userID, groupID int64, raw []byte, h if err != nil { return nil, err } - if entry.record.Provider == "codex" { - // The first native Codex client pins the account's platform; later clients on any machine reuse it. - if platform := accounts.CodexClientPlatform(headers.Get("User-Agent")); platform != "" { - if learnErr := s.accounts.LearnClientPlatform(ctx, id, platform); learnErr != nil { - entry.log(slog.LevelWarn, "Unable to pin client platform", "client_platform_failed") - } + // The first native client pins the account's fingerprint; later clients on any machine reuse it. + if profile, ok := accounts.ObservedProfile(entry.record.Provider, headers); ok { + if learnErr := s.accounts.LearnClientProfile(ctx, id, profile); learnErr != nil { + entry.log(slog.LevelWarn, "Unable to pin client fingerprint", "client_profile_failed") } } outgoing := headers.Clone() diff --git a/internal/server/accounts.go b/internal/server/accounts.go index 11cb8aa3..1a366ead 100644 --- a/internal/server/accounts.go +++ b/internal/server/accounts.go @@ -30,6 +30,9 @@ func (h *accountHTTP) register(router chi.Router) { accounts.Get("/runtime", h.runtime) accounts.Patch("/{id}/limits", h.limits) accounts.Put("/{id}/proxy", h.bindProxy) + accounts.Get("/{id}/fingerprint", h.fingerprint) + accounts.Put("/{id}/fingerprint", h.setFingerprint) + accounts.Delete("/{id}/fingerprint", h.resetFingerprint) accounts.Post("/{id}/resume", h.resume) accounts.Post("/import", h.importCredential) accounts.Post("/api-key", h.saveAPIKey) @@ -225,6 +228,49 @@ func (h *accountHTTP) bindProxy(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, account) } +func (h *accountHTTP) fingerprint(w http.ResponseWriter, r *http.Request) { + fingerprint, err := h.service.Fingerprint(r.Context(), chi.URLParam(r, "id")) + if err != nil { + accountError(w, err) + return + } + writeJSON(w, 200, fingerprint) +} + +func (h *accountHTTP) setFingerprint(w http.ResponseWriter, r *http.Request) { + var input struct { + Platform string `json:"platform"` + OS string `json:"os"` + Arch string `json:"arch"` + RuntimeVersion string `json:"runtime_version"` + PackageVersion string `json:"package_version"` + } + if !decodeJSON(w, r, &input) { + return + } + fingerprint, err := h.service.SetFingerprint(r.Context(), chi.URLParam(r, "id"), accounts.ClientProfile{Platform: input.Platform, OS: input.OS, Arch: input.Arch, RuntimeVersion: input.RuntimeVersion, PackageVersion: input.PackageVersion}) + if err != nil { + accountError(w, err) + return + } + writeJSON(w, 200, fingerprint) +} + +func (h *accountHTTP) resetFingerprint(w http.ResponseWriter, r *http.Request) { + var input struct { + Device bool `json:"device"` + } + if !decodeJSON(w, r, &input) { + return + } + fingerprint, err := h.service.ResetFingerprint(r.Context(), chi.URLParam(r, "id"), input.Device) + if err != nil { + accountError(w, err) + return + } + writeJSON(w, 200, fingerprint) +} + func (h *accountHTTP) remove(w http.ResponseWriter, r *http.Request) { var input struct{} if !decodeJSON(w, r, &input) { @@ -305,6 +351,8 @@ func accountError(w http.ResponseWriter, err error) { status, code = 409, err.Error() case errors.Is(err, upstream.ErrUsageUnsupported): status, code = 400, "provider_usage_unsupported" + case errors.Is(err, accounts.ErrFingerprintUnsupported): + status, code = 400, "fingerprint_unsupported" case errors.Is(err, accounts.ErrInput): status, code = 400, "invalid_account_input" case errors.Is(err, accounts.ErrProviderDisabled): diff --git a/internal/server/audit.go b/internal/server/audit.go index 7be8aedf..546f5353 100644 --- a/internal/server/audit.go +++ b/internal/server/audit.go @@ -158,6 +158,12 @@ func auditTarget(r *http.Request) (string, string, string) { if tail == "proxy" && r.Method == "PUT" { action = "account.proxy" } + if tail == "fingerprint" && r.Method == "PUT" { + action = "account.fingerprint" + } + if tail == "fingerprint" && r.Method == "DELETE" { + action = "account.fingerprint_reset" + } if tail == "" && r.Method == "PATCH" { action = "account.update" } diff --git a/internal/storage/db/accounts.sql.go b/internal/storage/db/accounts.sql.go index c9837ef9..8fd5e33a 100644 --- a/internal/storage/db/accounts.sql.go +++ b/internal/storage/db/accounts.sql.go @@ -93,7 +93,7 @@ func (q *Queries) DeleteAccount(ctx context.Context, arg DeleteAccountParams) (i } const getAccount = `-- name: GetAccount :one -SELECT id, provider, name, account_id, email, "plan", enabled, status, credential, expires_at, created_at, updated_at, max_concurrency, models_snapshot, models_revision, tenant_id, proxy_id, reset_config, reset_revision, reset_next_run_at, reset_time_zone, reset_check_at, usage_revision, client_platform FROM accounts WHERE id = ?1 AND tenant_id = ?2 +SELECT id, provider, name, account_id, email, "plan", enabled, status, credential, expires_at, created_at, updated_at, max_concurrency, models_snapshot, models_revision, tenant_id, proxy_id, reset_config, reset_revision, reset_next_run_at, reset_time_zone, reset_check_at, usage_revision, client_profile FROM accounts WHERE id = ?1 AND tenant_id = ?2 ` type GetAccountParams struct { @@ -128,25 +128,25 @@ func (q *Queries) GetAccount(ctx context.Context, arg GetAccountParams) (Account &i.ResetTimeZone, &i.ResetCheckAt, &i.UsageRevision, - &i.ClientPlatform, + &i.ClientProfile, ) return i, err } -const learnClientPlatform = `-- name: LearnClientPlatform :execrows -UPDATE accounts SET client_platform = ?1 -WHERE id = ?2 AND tenant_id = ?3 AND client_platform IS NULL +const learnClientProfile = `-- name: LearnClientProfile :execrows +UPDATE accounts SET client_profile = ?1 +WHERE id = ?2 AND tenant_id = ?3 AND client_profile IS NULL ` -type LearnClientPlatformParams struct { - ClientPlatform *string - ID string - TenantID int64 +type LearnClientProfileParams struct { + ClientProfile *string + ID string + TenantID int64 } -// First observation wins; a pinned platform never changes with later clients. -func (q *Queries) LearnClientPlatform(ctx context.Context, arg LearnClientPlatformParams) (int64, error) { - result, err := q.db.ExecContext(ctx, learnClientPlatform, arg.ClientPlatform, arg.ID, arg.TenantID) +// First observation wins; a pinned or manually set profile never changes with later clients. +func (q *Queries) LearnClientProfile(ctx context.Context, arg LearnClientProfileParams) (int64, error) { + result, err := q.db.ExecContext(ctx, learnClientProfile, arg.ClientProfile, arg.ID, arg.TenantID) if err != nil { return 0, err } @@ -265,6 +265,31 @@ func (q *Queries) SetAccountStatus(ctx context.Context, arg SetAccountStatusPara return err } +const setClientProfile = `-- name: SetClientProfile :execrows +UPDATE accounts SET client_profile = ?1, updated_at = ?2 +WHERE id = ?3 AND tenant_id = ?4 +` + +type SetClientProfileParams struct { + ClientProfile *string + UpdatedAt int64 + ID string + TenantID int64 +} + +func (q *Queries) SetClientProfile(ctx context.Context, arg SetClientProfileParams) (int64, error) { + result, err := q.db.ExecContext(ctx, setClientProfile, + arg.ClientProfile, + arg.UpdatedAt, + arg.ID, + arg.TenantID, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const updateAccountCredential = `-- name: UpdateAccountCredential :exec UPDATE accounts SET account_id = ?1, credential = ?2, email = ?3, plan = ?4, status = ?5, expires_at = ?6, updated_at = ?7 WHERE id = ?8 diff --git a/internal/storage/db/models.go b/internal/storage/db/models.go index 966d3efc..7b339eb6 100644 --- a/internal/storage/db/models.go +++ b/internal/storage/db/models.go @@ -28,7 +28,7 @@ type Account struct { ResetTimeZone string ResetCheckAt int64 UsageRevision int64 - ClientPlatform *string + ClientProfile *string } type AccountGroup struct { diff --git a/internal/storage/migrations/038_client_profile.sql b/internal/storage/migrations/038_client_profile.sql new file mode 100644 index 00000000..282f0a1f --- /dev/null +++ b/internal/storage/migrations/038_client_profile.sql @@ -0,0 +1,5 @@ +-- One client fingerprint per account for every provider that has one (Codex platform, Claude +-- device platform and runtime). Replaces the Codex-only client_platform column from 037. +ALTER TABLE accounts ADD COLUMN client_profile TEXT CHECK(client_profile IS NULL OR (length(client_profile) <= 1024 AND json_valid(client_profile))); +UPDATE accounts SET client_profile = json_object('source', 'learned', 'platform', client_platform) WHERE client_platform IS NOT NULL; +ALTER TABLE accounts DROP COLUMN client_platform; diff --git a/internal/storage/queries/accounts.sql b/internal/storage/queries/accounts.sql index f715c50b..e942605e 100644 --- a/internal/storage/queries/accounts.sql +++ b/internal/storage/queries/accounts.sql @@ -35,7 +35,11 @@ DELETE FROM accounts WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id) -- name: RenameAccount :exec UPDATE accounts SET name = sqlc.arg(name), updated_at = sqlc.arg(updated_at) WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id); --- name: LearnClientPlatform :execrows --- First observation wins; a pinned platform never changes with later clients. -UPDATE accounts SET client_platform = sqlc.arg(client_platform) -WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id) AND client_platform IS NULL; +-- name: LearnClientProfile :execrows +-- First observation wins; a pinned or manually set profile never changes with later clients. +UPDATE accounts SET client_profile = sqlc.arg(client_profile) +WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id) AND client_profile IS NULL; + +-- name: SetClientProfile :execrows +UPDATE accounts SET client_profile = sqlc.narg(client_profile), updated_at = sqlc.arg(updated_at) +WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id); diff --git a/internal/upstream/channel.go b/internal/upstream/channel.go index 91787d07..1ca9e853 100644 --- a/internal/upstream/channel.go +++ b/internal/upstream/channel.go @@ -108,7 +108,7 @@ func (c *Client) executionTransport(credential accounts.Credential, headers http if credential.Kind() == "openai" { base = &channelTransport{base: base, credential: credential, session: channelSession(headers, body)} } - return &engineTransport{base: base, codexVersion: c.codexVersion(), codexPlatform: credential.ClientPlatform, grokVersion: c.currentGrokVersion(), provider: credential.Kind()} + return &engineTransport{base: base, codexVersion: c.codexVersion(), codexPlatform: credential.Profile.Platform, claudeProfile: credential.Profile, grokVersion: c.currentGrokVersion(), provider: credential.Kind()} } // The pinned SDK's compatible executor always generates Chat Completions. diff --git a/internal/upstream/client.go b/internal/upstream/client.go index c49204a4..f1c65be6 100644 --- a/internal/upstream/client.go +++ b/internal/upstream/client.go @@ -405,7 +405,7 @@ func upstreamHeaders(request *http.Request, credential accounts.Credential, clie request.Header.Set("Content-Type", "application/json") request.Header.Set("Authorization", "Bearer "+credential.AccessToken) request.Header.Set("Chatgpt-Account-Id", credential.AccountID) - applyCodexVersion(request.Header, version, credential.ClientPlatform) + applyCodexVersion(request.Header, version, credential.Profile.Platform) request.Header.Set("Accept", "application/json") if stream { request.Header.Set("Accept", "text/event-stream") diff --git a/internal/upstream/engine.go b/internal/upstream/engine.go index 0bc8ead8..885bed01 100644 --- a/internal/upstream/engine.go +++ b/internal/upstream/engine.go @@ -419,6 +419,7 @@ type engineTransport struct { maxResponseBytes int64 codexVersion string codexPlatform string + claudeProfile accounts.ClientProfile grokVersion string base http.RoundTripper mu sync.Mutex @@ -438,6 +439,10 @@ func (t *engineTransport) RoundTrip(r *http.Request) (*http.Response, error) { r = r.Clone(r.Context()) applyCodexVersion(r.Header, t.codexVersion, t.codexPlatform) } + if t.provider == "claude" && r.URL.Host == "api.anthropic.com" { + // The account's own platform replaces the SDK's shared baseline; empty fields keep it. + r = applyClaudeProfile(r, t.claudeProfile) + } if t.grokVersion != "" && r.URL.Host == "cli-chat-proxy.grok.com" && strings.HasPrefix(r.URL.Path, "/v1/") { // Apply the durable policy after the pinned SDK's obsolete headers, without mutating its request. r = r.Clone(r.Context()) @@ -585,3 +590,18 @@ func (t *engineTransport) roundTripConnect(r *http.Request) (*http.Response, err } } } + +func applyClaudeProfile(r *http.Request, profile accounts.ClientProfile) *http.Request { + values := [][2]string{{"X-Stainless-Os", profile.OS}, {"X-Stainless-Arch", profile.Arch}, {"X-Stainless-Runtime-Version", profile.RuntimeVersion}, {"X-Stainless-Package-Version", profile.PackageVersion}} + cloned := false + for _, value := range values { + if value[1] == "" { + continue + } + if !cloned { + r, cloned = r.Clone(r.Context()), true + } + r.Header.Set(value[0], value[1]) + } + return r +} diff --git a/internal/upstream/fingerprint_test.go b/internal/upstream/fingerprint_test.go index 77310a82..e82794da 100644 --- a/internal/upstream/fingerprint_test.go +++ b/internal/upstream/fingerprint_test.go @@ -9,6 +9,8 @@ import ( "syscall" "testing" "time" + + "github.com/murongg/SubLane/internal/accounts" ) func TestCodexAgentPresentsAccountPlatform(t *testing.T) { @@ -65,3 +67,16 @@ func TestConnectFailuresRetryOnlyBeforeRequestBytes(t *testing.T) { }) } } + +func TestClaudeProfileOverridesOnlySetHeaders(t *testing.T) { + request, _ := http.NewRequest(http.MethodPost, "https://api.anthropic.com/v1/messages", nil) + request.Header.Set("X-Stainless-Os", "MacOS") + request.Header.Set("X-Stainless-Runtime-Version", "v26.3.0") + got := applyClaudeProfile(request, accounts.ClientProfile{OS: "Windows", Arch: "x64"}) + if got.Header.Get("X-Stainless-Os") != "Windows" || got.Header.Get("X-Stainless-Arch") != "x64" || got.Header.Get("X-Stainless-Runtime-Version") != "v26.3.0" { + t.Fatal("account profile not applied", got.Header) + } + if request.Header.Get("X-Stainless-Os") != "MacOS" { + t.Fatal("SDK request was mutated") + } +} diff --git a/web/src/components/AccountFingerprint.test.tsx b/web/src/components/AccountFingerprint.test.tsx new file mode 100644 index 00000000..3921c50f --- /dev/null +++ b/web/src/components/AccountFingerprint.test.tsx @@ -0,0 +1,107 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { expect, it, vi } from 'vitest' +import { AccountFingerprint } from './AccountFingerprint' + +function json(body: unknown) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) +} +function mount(onClose = vi.fn()) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + render( + + + , + ) + return onClose +} + +it('saves a manual Codex platform with the spaces it contains', async () => { + const calls: { method: string; body: unknown }[] = [] + vi.stubGlobal( + 'fetch', + vi.fn((_: string, init?: RequestInit) => { + const method = init?.method ?? 'GET' + if (method !== 'GET') + calls.push({ method, body: JSON.parse(String(init?.body)) }) + return Promise.resolve( + json({ + provider: 'codex', + profile: { + source: method === 'PUT' ? 'manual' : 'learned', + platform: '(Mac OS 15.5.0; arm64) iTerm.app/3.6.1', + }, + }), + ) + }), + ) + const onClose = mount() + const field = await screen.findByLabelText('Platform and terminal') + expect(screen.getByText('Learned from a native client')).toBeTruthy() + const user = userEvent.setup() + await user.clear(field) + await user.type(field, ' (Windows 10.0.26100; x86_64) WindowsTerminal ') + await user.click(screen.getByRole('button', { name: 'Save fingerprint' })) + await waitFor(() => expect(onClose).toHaveBeenCalled()) + expect(calls).toEqual([ + { + method: 'PUT', + body: { + platform: '(Windows 10.0.26100; x86_64) WindowsTerminal', + os: '', + arch: '', + runtime_version: '', + package_version: '', + }, + }, + ]) +}) + +it('resets a Claude fingerprint and optionally its device', async () => { + const calls: { method: string; body: unknown }[] = [] + let device = '1a2b3c4d' + vi.stubGlobal( + 'fetch', + vi.fn((_: string, init?: RequestInit) => { + const method = init?.method ?? 'GET' + if (method === 'DELETE') { + calls.push({ method, body: JSON.parse(String(init?.body)) }) + device = '5e6f7a8b' + return Promise.resolve( + json({ provider: 'claude', profile: {}, device_id: device }), + ) + } + return Promise.resolve( + json({ + provider: 'claude', + profile: { source: 'learned', os: 'Windows', arch: 'x64' }, + device_id: device, + }), + ) + }), + ) + mount() + const os = (await screen.findByLabelText( + 'Operating system', + )) as HTMLSelectElement + expect(os.value).toBe('Windows') + const user = userEvent.setup() + await user.click( + screen.getByRole('checkbox', { name: 'Also generate a new device ID' }), + ) + await user.click(screen.getByRole('button', { name: 'Reset' })) + await waitFor(() => + expect(calls).toEqual([{ method: 'DELETE', body: { device: true } }]), + ) + await waitFor(() => expect(screen.getByText('5e6f7a8b…')).toBeTruthy()) +}) diff --git a/web/src/components/AccountFingerprint.tsx b/web/src/components/AccountFingerprint.tsx new file mode 100644 index 00000000..e615b977 --- /dev/null +++ b/web/src/components/AccountFingerprint.tsx @@ -0,0 +1,304 @@ +import { useEffect, useState, type FormEvent } from 'react' +import { useMutation, useQuery } from '@tanstack/react-query' +import { LoaderCircle } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { + accountErrorKey, + fetchAccountFingerprint, + resetAccountFingerprint, + saveAccountFingerprint, + type Account, + type AccountFingerprint as Fingerprint, + type FingerprintInput, +} from '@/lib/accounts' +import { Button } from './ui/Button' +import { Input } from './ui/Input' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from './ui/Dialog' + +const empty: FingerprintInput = { + platform: '', + os: '', + arch: '', + runtime_version: '', + package_version: '', +} +// The Stainless values the Anthropic SDK reports on the platforms Claude Code runs on. +const claudeOS = ['MacOS', 'Windows', 'Linux', 'FreeBSD'] +const claudeArch = ['arm64', 'x64', 'x86', 'arm'] +const selectClass = + 'h-9 w-full rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50' + +function profileInput(fingerprint: Fingerprint): FingerprintInput { + const { platform, os, arch, runtime_version, package_version } = + fingerprint.profile + return { platform, os, arch, runtime_version, package_version } +} + +export function AccountFingerprint({ + account, + onClose, + restoreFocus, +}: { + account: Pick + onClose: () => void + restoreFocus: () => void +}) { + const { t } = useTranslation() + const query = useQuery({ + queryKey: ['account-fingerprint', account.id], + queryFn: () => fetchAccountFingerprint(account.id), + }) + const [values, setValues] = useState(empty) + const [device, setDevice] = useState(false) + useEffect(() => { + if (query.data) setValues(profileInput(query.data)) + }, [query.data]) + const save = useMutation({ + mutationFn: saveAccountFingerprint, + onSuccess: onClose, + }) + const reset = useMutation({ + mutationFn: resetAccountFingerprint, + onSuccess: (fingerprint) => { + setValues(profileInput(fingerprint)) + setDevice(false) + return query.refetch() + }, + }) + const busy = save.isPending || reset.isPending + const failure = save.error ?? reset.error + const set = (key: keyof FingerprintInput) => (value: string) => + setValues((current) => ({ ...current, [key]: value })) + const submit = (event: FormEvent) => { + event.preventDefault() + if (!query.data || busy) return + reset.reset() + const profile = Object.fromEntries( + Object.entries(values).map(([key, value]) => [key, value.trim()]), + ) as FingerprintInput + save.mutate({ id: account.id, profile }) + } + const source = query.data?.profile.source ?? '' + return ( + { + if (!open && !busy) onClose() + }} + > + { + if (busy) event.preventDefault() + }} + onEscapeKeyDown={(event) => { + if (busy) event.preventDefault() + }} + onCloseAutoFocus={(event) => { + event.preventDefault() + restoreFocus() + }} + > + + {t('accountFingerprint')} + + {account.name} · {t('accountFingerprintHint')} + + + {query.isPending ? ( +

+ {t('loadingFingerprint')} +

+ ) : query.isError ? ( +
+

{t(accountErrorKey(query.error))}

+ +
+ ) : ( +
+

+ {t( + source === 'learned' + ? 'fingerprintSourceLearned' + : source === 'manual' + ? 'fingerprintSourceManual' + : 'fingerprintSourceNone', + )} +

+ {query.data.provider === 'codex' ? ( +
+ + set('platform')(event.target.value)} + placeholder="(Mac OS 15.5.0; arm64) iTerm.app/3.6.1" + aria-describedby="fingerprint-platform-hint" + maxLength={160} + disabled={busy} + /> +

+ {t('fingerprintCodexPlatformHint')} +

+
+ ) : ( +
+
+ {( + [ + ['os', 'fingerprintClaudeOS', claudeOS], + ['arch', 'fingerprintClaudeArch', claudeArch], + ] as const + ).map(([key, label, options]) => ( +
+ + +
+ ))} + {( + [ + [ + 'runtime_version', + 'fingerprintRuntimeVersion', + 'v26.3.0', + ], + [ + 'package_version', + 'fingerprintPackageVersion', + '0.112.1', + ], + ] as const + ).map(([key, label, placeholder]) => ( +
+ + set(key)(event.target.value)} + placeholder={`${t('fingerprintAutomatic')} · ${placeholder}`} + maxLength={32} + disabled={busy} + /> +
+ ))} +
+

+ {t('fingerprintClaudeHint')} +

+ {query.data.device_id && ( +

+ {t('fingerprintDevice')}:{' '} + {query.data.device_id}… +

+ )} + +

+ {t('fingerprintResetDeviceHint')} +

+
+ )} + {failure && ( +

+ {t(accountErrorKey(failure))} +

+ )} + + + + + +
+ )} +
+
+ ) +} diff --git a/web/src/lib/accounts.ts b/web/src/lib/accounts.ts index 5cb8548a..417043a9 100644 --- a/web/src/lib/accounts.ts +++ b/web/src/lib/accounts.ts @@ -140,6 +140,48 @@ export function bindAccountProxy(input: { id: string; proxy_id: string }) { { method: 'PUT', body: JSON.stringify({ proxy_id: input.proxy_id }) }, ) } +const fingerprintProfileSchema = z.object({ + source: z.enum(['', 'learned', 'manual']).default(''), + platform: z.string().max(160).default(''), + os: z.string().max(32).default(''), + arch: z.string().max(32).default(''), + runtime_version: z.string().max(32).default(''), + package_version: z.string().max(32).default(''), +}) +const fingerprintSchema = z.object({ + provider: z.enum(['codex', 'claude']), + profile: fingerprintProfileSchema, + device_id: z.string().max(8).default(''), +}) +export type AccountFingerprint = z.infer +export type FingerprintInput = Omit +export const fingerprintProviders = new Set(['codex', 'claude']) +export function fetchAccountFingerprint(id: string) { + return request( + `/api/accounts/${encodeURIComponent(id)}/fingerprint`, + fingerprintSchema, + ) +} +export function saveAccountFingerprint(input: { + id: string + profile: FingerprintInput +}) { + return request( + `/api/accounts/${encodeURIComponent(input.id)}/fingerprint`, + fingerprintSchema, + { method: 'PUT', body: JSON.stringify(input.profile) }, + ) +} +export function resetAccountFingerprint(input: { + id: string + device: boolean +}) { + return request( + `/api/accounts/${encodeURIComponent(input.id)}/fingerprint`, + fingerprintSchema, + { method: 'DELETE', body: JSON.stringify({ device: input.device }) }, + ) +} export function deleteAccount(id: string) { return request(`/api/accounts/${encodeURIComponent(id)}`, z.undefined(), { method: 'DELETE', @@ -166,6 +208,7 @@ const messages = { account_reauthorization_required: 'accountReauthorizeHint', account_refresh_failed: 'accountRefreshFailed', invalid_proxy_input: 'proxyInvalidInput', + fingerprint_unsupported: 'fingerprintUnsupported', proxy_not_found: 'proxyNotFound', oauth_state_invalid: 'oauthStateInvalid', oauth_callback_invalid: 'oauthCallbackInvalid', diff --git a/web/src/lib/audit.ts b/web/src/lib/audit.ts index 563fbe33..b3d51c33 100644 --- a/web/src/lib/audit.ts +++ b/web/src/lib/audit.ts @@ -45,6 +45,8 @@ export const auditActions: Record = { 'account.concurrency': 'auditAccountConcurrency', 'account.resume': 'auditAccountResume', 'account.proxy': 'auditAccountProxy', + 'account.fingerprint': 'auditAccountFingerprint', + 'account.fingerprint_reset': 'auditAccountFingerprintReset', 'account.reset_settings': 'auditAccountResetSettings', 'account.reset_use': 'auditAccountResetUse', 'proxy.create': 'auditProxyCreate', diff --git a/web/src/locales/ar.json b/web/src/locales/ar.json index a6f087ab..adb03038 100644 --- a/web/src/locales/ar.json +++ b/web/src/locales/ar.json @@ -599,6 +599,28 @@ "auditAccountConcurrency": "تطابق الحسابات المتغيرة", "auditAccountResume": "تسوية الحسابات المصفوفة", "auditAccountProxy": "موكب لشبكة الحسابات المتغيرة", + "accountFingerprint": "بصمة العميل", + "accountFingerprintHint": "يقدّم كل عميل يستخدم هذا الحساب هذه البصمة للمزوّد، فتبدو العملاء على أجهزة مختلفة كأنها جهاز واحد. تُتعلَّم الحقول الفارغة من العميل الأصلي التالي أو تستخدم القيم الافتراضية التي يديرها SubLane.", + "fingerprintSourceLearned": "تم تعلّمها من عميل أصلي", + "fingerprintSourceManual": "مضبوطة يدويًا", + "fingerprintSourceNone": "لم تُتعلَّم بعد. سيضبطها العميل الأصلي التالي.", + "fingerprintCodexPlatform": "المنصة والطرفية", + "fingerprintCodexPlatformHint": "جزء المنصة من User-Agent الخاص بـ Codex، مثل (Mac OS 15.5.0; arm64) iTerm.app/3.6.1. يدير SubLane إصدار Codex.", + "fingerprintClaudeOS": "نظام التشغيل", + "fingerprintClaudeArch": "البنية", + "fingerprintRuntimeVersion": "إصدار بيئة التشغيل", + "fingerprintPackageVersion": "إصدار SDK", + "fingerprintClaudeHint": "تُرسل في ترويسات X-Stainless. يُتعلَّم نظام التشغيل والبنية من أول عميل Claude Code، وتتبع إصدارات بيئة التشغيل وSDK إصدار Claude Code المُدار ما لم تُضبط هنا.", + "fingerprintAutomatic": "تلقائي", + "fingerprintDevice": "معرّف الجهاز", + "fingerprintResetDevice": "إنشاء معرّف جهاز جديد أيضًا", + "fingerprintResetDeviceHint": "سيرى المزوّد هذا الحساب على جهاز جديد.", + "resetFingerprint": "إعادة تعيين", + "saveFingerprint": "حفظ البصمة", + "loadingFingerprint": "جارٍ تحميل البصمة…", + "fingerprintUnsupported": "لا توجد بصمة عميل لهذا النوع من الحسابات.", + "auditAccountFingerprint": "تم تغيير بصمة عميل الحساب", + "auditAccountFingerprintReset": "تمت إعادة تعيين بصمة عميل الحساب", "auditProxyCreate": "شبكة مُنشأة", "auditProxyUpdate": "تحديث الشبكة", "auditProxyDelete": "شبكة مهجورة", diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 8b36e50c..00f3825d 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -599,6 +599,28 @@ "auditAccountConcurrency": "Änderung der Kontokonkurrenz", "auditAccountResume": "Abklingzeit auf dem Konto", "auditAccountProxy": "Netzwerk-Proxy mit geändertem Konto", + "accountFingerprint": "Client-Fingerabdruck", + "accountFingerprintHint": "Alle Clients dieses Kontos treten beim Anbieter mit diesem Fingerabdruck auf, sodass Clients auf verschiedenen Rechnern wie ein Gerät wirken. Leere Felder werden vom nächsten nativen Client gelernt oder nutzen die von SubLane verwalteten Standardwerte.", + "fingerprintSourceLearned": "Von einem nativen Client gelernt", + "fingerprintSourceManual": "Manuell festgelegt", + "fingerprintSourceNone": "Noch nicht gelernt. Der nächste native Client legt ihn fest.", + "fingerprintCodexPlatform": "Plattform und Terminal", + "fingerprintCodexPlatformHint": "Der Plattformteil des Codex-User-Agents, zum Beispiel (Mac OS 15.5.0; arm64) iTerm.app/3.6.1. SubLane verwaltet die Codex-Version.", + "fingerprintClaudeOS": "Betriebssystem", + "fingerprintClaudeArch": "Architektur", + "fingerprintRuntimeVersion": "Laufzeitversion", + "fingerprintPackageVersion": "SDK-Version", + "fingerprintClaudeHint": "Wird als X-Stainless-Header gesendet. Betriebssystem und Architektur werden vom ersten Claude-Code-Client gelernt; Laufzeit- und SDK-Version folgen der verwalteten Claude-Code-Version, sofern hier nichts festgelegt ist.", + "fingerprintAutomatic": "Automatisch", + "fingerprintDevice": "Geräte-ID", + "fingerprintResetDevice": "Auch eine neue Geräte-ID erzeugen", + "fingerprintResetDeviceHint": "Der Anbieter sieht dieses Konto auf einem neuen Gerät.", + "resetFingerprint": "Zurücksetzen", + "saveFingerprint": "Fingerabdruck speichern", + "loadingFingerprint": "Fingerabdruck wird geladen…", + "fingerprintUnsupported": "Diese Kontoart hat keinen Client-Fingerabdruck.", + "auditAccountFingerprint": "Client-Fingerabdruck des Kontos geändert", + "auditAccountFingerprintReset": "Client-Fingerabdruck des Kontos zurückgesetzt", "auditProxyCreate": "Netzwerk-Proxy erstellt", "auditProxyUpdate": "Aktualisierter Netzwerk-Proxy", "auditProxyDelete": "Netzwerk-Proxy gelöscht", diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index f3ced7d5..72d720b7 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -929,6 +929,32 @@ export const en = { auditAccountConcurrency: 'Changed account concurrency', auditAccountResume: 'Cleared account cooldown', auditAccountProxy: 'Changed account network proxy', + accountFingerprint: 'Client fingerprint', + accountFingerprintHint: + 'Every client using this account presents this fingerprint upstream, so clients on different machines look like one device. Empty fields are learned from the next native client or use SubLane’s managed defaults.', + fingerprintSourceLearned: 'Learned from a native client', + fingerprintSourceManual: 'Set manually', + fingerprintSourceNone: 'Not learned yet. The next native client sets it.', + fingerprintCodexPlatform: 'Platform and terminal', + fingerprintCodexPlatformHint: + 'The platform part of the Codex User-Agent, for example (Mac OS 15.5.0; arm64) iTerm.app/3.6.1. SubLane manages the Codex version.', + fingerprintClaudeOS: 'Operating system', + fingerprintClaudeArch: 'Architecture', + fingerprintRuntimeVersion: 'Runtime version', + fingerprintPackageVersion: 'SDK version', + fingerprintClaudeHint: + 'Sent as X-Stainless headers. Operating system and architecture are learned from the first Claude Code client; runtime and SDK versions follow the managed Claude Code release unless set here.', + fingerprintAutomatic: 'Automatic', + fingerprintDevice: 'Device ID', + fingerprintResetDevice: 'Also generate a new device ID', + fingerprintResetDeviceHint: + 'The provider will see this account on a new device.', + resetFingerprint: 'Reset', + saveFingerprint: 'Save fingerprint', + loadingFingerprint: 'Loading fingerprint…', + fingerprintUnsupported: 'This account type has no client fingerprint.', + auditAccountFingerprint: 'Changed account client fingerprint', + auditAccountFingerprintReset: 'Reset account client fingerprint', auditProxyCreate: 'Created network proxy', auditProxyUpdate: 'Updated network proxy', auditProxyDelete: 'Deleted network proxy', diff --git a/web/src/locales/es.json b/web/src/locales/es.json index eecfe40c..9aa100ed 100644 --- a/web/src/locales/es.json +++ b/web/src/locales/es.json @@ -599,6 +599,28 @@ "auditAccountConcurrency": "Simultaneidad de cuenta modificada", "auditAccountResume": "Enfriamiento de cuenta borrado", "auditAccountProxy": "Proxy de red de cuenta modificada", + "accountFingerprint": "Huella del cliente", + "accountFingerprintHint": "Todos los clientes que usan esta cuenta presentan esta huella al proveedor, así que clientes en equipos distintos parecen un solo dispositivo. Los campos vacíos se aprenden del siguiente cliente nativo o usan los valores gestionados por SubLane.", + "fingerprintSourceLearned": "Aprendida de un cliente nativo", + "fingerprintSourceManual": "Configurada manualmente", + "fingerprintSourceNone": "Aún no aprendida. El siguiente cliente nativo la establecerá.", + "fingerprintCodexPlatform": "Plataforma y terminal", + "fingerprintCodexPlatformHint": "La parte de plataforma del User-Agent de Codex, por ejemplo (Mac OS 15.5.0; arm64) iTerm.app/3.6.1. SubLane gestiona la versión de Codex.", + "fingerprintClaudeOS": "Sistema operativo", + "fingerprintClaudeArch": "Arquitectura", + "fingerprintRuntimeVersion": "Versión del entorno de ejecución", + "fingerprintPackageVersion": "Versión del SDK", + "fingerprintClaudeHint": "Se envía como encabezados X-Stainless. El sistema operativo y la arquitectura se aprenden del primer cliente de Claude Code; las versiones del entorno y del SDK siguen la versión gestionada de Claude Code salvo que se indiquen aquí.", + "fingerprintAutomatic": "Automático", + "fingerprintDevice": "ID de dispositivo", + "fingerprintResetDevice": "Generar también un nuevo ID de dispositivo", + "fingerprintResetDeviceHint": "El proveedor verá esta cuenta en un dispositivo nuevo.", + "resetFingerprint": "Restablecer", + "saveFingerprint": "Guardar huella", + "loadingFingerprint": "Cargando huella…", + "fingerprintUnsupported": "Este tipo de cuenta no tiene huella de cliente.", + "auditAccountFingerprint": "Huella de cliente de cuenta modificada", + "auditAccountFingerprintReset": "Huella de cliente de cuenta restablecida", "auditProxyCreate": "Proxy de red creado", "auditProxyUpdate": "Proxy de red actualizado", "auditProxyDelete": "Proxy de red eliminado", diff --git a/web/src/locales/fr.json b/web/src/locales/fr.json index 11b812f0..09db1065 100644 --- a/web/src/locales/fr.json +++ b/web/src/locales/fr.json @@ -599,6 +599,28 @@ "auditAccountConcurrency": "Cohérence de compte modifiée", "auditAccountResume": "Refroidissement du compte effacé", "auditAccountProxy": "Proxy réseau de compte modifié", + "accountFingerprint": "Empreinte client", + "accountFingerprintHint": "Tous les clients qui utilisent ce compte présentent cette empreinte au fournisseur ; des clients sur des machines différentes ressemblent ainsi à un seul appareil. Les champs vides sont appris du prochain client natif ou utilisent les valeurs gérées par SubLane.", + "fingerprintSourceLearned": "Apprise d’un client natif", + "fingerprintSourceManual": "Définie manuellement", + "fingerprintSourceNone": "Pas encore apprise. Le prochain client natif la définira.", + "fingerprintCodexPlatform": "Plateforme et terminal", + "fingerprintCodexPlatformHint": "La partie plateforme du User-Agent de Codex, par exemple (Mac OS 15.5.0; arm64) iTerm.app/3.6.1. SubLane gère la version de Codex.", + "fingerprintClaudeOS": "Système d’exploitation", + "fingerprintClaudeArch": "Architecture", + "fingerprintRuntimeVersion": "Version de l’environnement d’exécution", + "fingerprintPackageVersion": "Version du SDK", + "fingerprintClaudeHint": "Envoyée dans les en-têtes X-Stainless. Le système et l’architecture sont appris du premier client Claude Code ; les versions de l’environnement et du SDK suivent la version gérée de Claude Code sauf si elles sont définies ici.", + "fingerprintAutomatic": "Automatique", + "fingerprintDevice": "ID d’appareil", + "fingerprintResetDevice": "Générer aussi un nouvel ID d’appareil", + "fingerprintResetDeviceHint": "Le fournisseur verra ce compte sur un nouvel appareil.", + "resetFingerprint": "Réinitialiser", + "saveFingerprint": "Enregistrer l’empreinte", + "loadingFingerprint": "Chargement de l’empreinte…", + "fingerprintUnsupported": "Ce type de compte n’a pas d’empreinte client.", + "auditAccountFingerprint": "Empreinte client du compte modifiée", + "auditAccountFingerprintReset": "Empreinte client du compte réinitialisée", "auditProxyCreate": "Création d'un proxy réseau", "auditProxyUpdate": "Proxy réseau actualisé", "auditProxyDelete": "Proxy réseau supprimé", diff --git a/web/src/locales/ja.json b/web/src/locales/ja.json index 6b5bf15c..0c9b85e6 100644 --- a/web/src/locales/ja.json +++ b/web/src/locales/ja.json @@ -599,6 +599,28 @@ "auditAccountConcurrency": "アカウントの同時実行性の変更", "auditAccountResume": "アカウントのクールダウンをクリアしました", "auditAccountProxy": "アカウントネットワークプロキシを変更しました", + "accountFingerprint": "クライアントフィンガープリント", + "accountFingerprintHint": "このアカウントを使うすべてのクライアントは、このフィンガープリントで上流にアクセスします。別のマシンのクライアントも同じデバイスに見えます。空欄は次のネイティブクライアントから学習するか、SubLane の管理された既定値を使います。", + "fingerprintSourceLearned": "ネイティブクライアントから学習済み", + "fingerprintSourceManual": "手動設定", + "fingerprintSourceNone": "未学習です。次のネイティブクライアントが設定します。", + "fingerprintCodexPlatform": "プラットフォームとターミナル", + "fingerprintCodexPlatformHint": "Codex User-Agent のプラットフォーム部分です(例: (Mac OS 15.5.0; arm64) iTerm.app/3.6.1)。Codex のバージョンは SubLane が管理します。", + "fingerprintClaudeOS": "OS", + "fingerprintClaudeArch": "アーキテクチャ", + "fingerprintRuntimeVersion": "ランタイムバージョン", + "fingerprintPackageVersion": "SDK バージョン", + "fingerprintClaudeHint": "X-Stainless ヘッダーとして送信されます。OS とアーキテクチャは最初の Claude Code クライアントから学習し、ランタイムと SDK のバージョンはここで設定しない限り管理された Claude Code リリースに従います。", + "fingerprintAutomatic": "自動", + "fingerprintDevice": "デバイス ID", + "fingerprintResetDevice": "デバイス ID も再生成する", + "fingerprintResetDeviceHint": "上流からは新しいデバイスでの利用に見えます。", + "resetFingerprint": "リセット", + "saveFingerprint": "フィンガープリントを保存", + "loadingFingerprint": "フィンガープリントを読み込み中…", + "fingerprintUnsupported": "この種類のアカウントにはクライアントフィンガープリントがありません。", + "auditAccountFingerprint": "アカウントのクライアントフィンガープリントを変更", + "auditAccountFingerprintReset": "アカウントのクライアントフィンガープリントをリセット", "auditProxyCreate": "作成されたネットワークプロキシ", "auditProxyUpdate": "更新されたネットワークプロキシ", "auditProxyDelete": "削除されたネットワークプロキシ", diff --git a/web/src/locales/ko.json b/web/src/locales/ko.json index 9a2f7675..d4f0283b 100644 --- a/web/src/locales/ko.json +++ b/web/src/locales/ko.json @@ -599,6 +599,28 @@ "auditAccountConcurrency": "변경된 계정 동시성", "auditAccountResume": "계정 쿨다운이 삭제되었습니다.", "auditAccountProxy": "계정 네트워크 프록시가 변경되었습니다.", + "accountFingerprint": "클라이언트 지문", + "accountFingerprintHint": "이 계정을 쓰는 모든 클라이언트는 이 지문으로 업스트림에 접근하므로, 다른 기기의 클라이언트도 같은 기기로 보입니다. 비워 둔 항목은 다음 네이티브 클라이언트에서 학습하거나 SubLane이 관리하는 기본값을 사용합니다.", + "fingerprintSourceLearned": "네이티브 클라이언트에서 학습됨", + "fingerprintSourceManual": "수동 설정", + "fingerprintSourceNone": "아직 학습되지 않았습니다. 다음 네이티브 클라이언트가 설정합니다.", + "fingerprintCodexPlatform": "플랫폼 및 터미널", + "fingerprintCodexPlatformHint": "Codex User-Agent의 플랫폼 부분입니다(예: (Mac OS 15.5.0; arm64) iTerm.app/3.6.1). Codex 버전은 SubLane이 관리합니다.", + "fingerprintClaudeOS": "운영체제", + "fingerprintClaudeArch": "아키텍처", + "fingerprintRuntimeVersion": "런타임 버전", + "fingerprintPackageVersion": "SDK 버전", + "fingerprintClaudeHint": "X-Stainless 헤더로 전송됩니다. 운영체제와 아키텍처는 첫 Claude Code 클라이언트에서 학습하며, 런타임과 SDK 버전은 여기서 설정하지 않으면 관리되는 Claude Code 릴리스를 따릅니다.", + "fingerprintAutomatic": "자동", + "fingerprintDevice": "기기 ID", + "fingerprintResetDevice": "기기 ID도 새로 생성", + "fingerprintResetDeviceHint": "업스트림에서는 이 계정이 새 기기에서 사용되는 것으로 보입니다.", + "resetFingerprint": "초기화", + "saveFingerprint": "지문 저장", + "loadingFingerprint": "지문을 불러오는 중…", + "fingerprintUnsupported": "이 유형의 계정에는 클라이언트 지문이 없습니다.", + "auditAccountFingerprint": "계정 클라이언트 지문 변경", + "auditAccountFingerprintReset": "계정 클라이언트 지문 초기화", "auditProxyCreate": "네트워크 프록시가 생성되었습니다.", "auditProxyUpdate": "업데이트된 네트워크 프록시", "auditProxyDelete": "삭제된 네트워크 프록시", diff --git a/web/src/locales/pt.json b/web/src/locales/pt.json index 86fea082..e8ab49df 100644 --- a/web/src/locales/pt.json +++ b/web/src/locales/pt.json @@ -599,6 +599,28 @@ "auditAccountConcurrency": "Concorrente da conta alterada", "auditAccountResume": "Apagamento da conta", "auditAccountProxy": "Proxy de rede de conta alterada", + "accountFingerprint": "Impressão digital do cliente", + "accountFingerprintHint": "Todos os clientes que usam esta conta apresentam esta impressão ao provedor, então clientes em máquinas diferentes parecem um único dispositivo. Campos vazios são aprendidos do próximo cliente nativo ou usam os padrões gerenciados pelo SubLane.", + "fingerprintSourceLearned": "Aprendida de um cliente nativo", + "fingerprintSourceManual": "Definida manualmente", + "fingerprintSourceNone": "Ainda não aprendida. O próximo cliente nativo vai defini-la.", + "fingerprintCodexPlatform": "Plataforma e terminal", + "fingerprintCodexPlatformHint": "A parte de plataforma do User-Agent do Codex, por exemplo (Mac OS 15.5.0; arm64) iTerm.app/3.6.1. O SubLane gerencia a versão do Codex.", + "fingerprintClaudeOS": "Sistema operacional", + "fingerprintClaudeArch": "Arquitetura", + "fingerprintRuntimeVersion": "Versão do runtime", + "fingerprintPackageVersion": "Versão do SDK", + "fingerprintClaudeHint": "Enviada nos cabeçalhos X-Stainless. Sistema operacional e arquitetura são aprendidos do primeiro cliente Claude Code; as versões de runtime e SDK seguem a versão gerenciada do Claude Code, a menos que sejam definidas aqui.", + "fingerprintAutomatic": "Automático", + "fingerprintDevice": "ID do dispositivo", + "fingerprintResetDevice": "Gerar também um novo ID de dispositivo", + "fingerprintResetDeviceHint": "O provedor verá esta conta em um novo dispositivo.", + "resetFingerprint": "Redefinir", + "saveFingerprint": "Salvar impressão", + "loadingFingerprint": "Carregando impressão…", + "fingerprintUnsupported": "Este tipo de conta não tem impressão digital de cliente.", + "auditAccountFingerprint": "Impressão digital do cliente da conta alterada", + "auditAccountFingerprintReset": "Impressão digital do cliente da conta redefinida", "auditProxyCreate": "Proxy de rede criado", "auditProxyUpdate": "Proxy de rede atualizado", "auditProxyDelete": "Proxy de rede apagada", diff --git a/web/src/locales/ru.json b/web/src/locales/ru.json index 681fd331..552ff724 100644 --- a/web/src/locales/ru.json +++ b/web/src/locales/ru.json @@ -599,6 +599,28 @@ "auditAccountConcurrency": "Измененная параллель аккаунта", "auditAccountResume": "Охлаждение аккаунта", "auditAccountProxy": "Измененный прокси-сервер сети аккаунтов", + "accountFingerprint": "Отпечаток клиента", + "accountFingerprintHint": "Все клиенты этого аккаунта обращаются к провайдеру с этим отпечатком, поэтому клиенты на разных машинах выглядят как одно устройство. Пустые поля берутся у следующего нативного клиента или из значений, которыми управляет SubLane.", + "fingerprintSourceLearned": "Получен от нативного клиента", + "fingerprintSourceManual": "Задан вручную", + "fingerprintSourceNone": "Ещё не получен. Его задаст следующий нативный клиент.", + "fingerprintCodexPlatform": "Платформа и терминал", + "fingerprintCodexPlatformHint": "Часть User-Agent Codex с платформой, например (Mac OS 15.5.0; arm64) iTerm.app/3.6.1. Версией Codex управляет SubLane.", + "fingerprintClaudeOS": "Операционная система", + "fingerprintClaudeArch": "Архитектура", + "fingerprintRuntimeVersion": "Версия среды выполнения", + "fingerprintPackageVersion": "Версия SDK", + "fingerprintClaudeHint": "Отправляется в заголовках X-Stainless. ОС и архитектура берутся у первого клиента Claude Code; версии среды выполнения и SDK следуют управляемому выпуску Claude Code, если не заданы здесь.", + "fingerprintAutomatic": "Автоматически", + "fingerprintDevice": "ID устройства", + "fingerprintResetDevice": "Также создать новый ID устройства", + "fingerprintResetDeviceHint": "Провайдер увидит этот аккаунт на новом устройстве.", + "resetFingerprint": "Сбросить", + "saveFingerprint": "Сохранить отпечаток", + "loadingFingerprint": "Загрузка отпечатка…", + "fingerprintUnsupported": "У этого типа аккаунта нет отпечатка клиента.", + "auditAccountFingerprint": "Изменён отпечаток клиента аккаунта", + "auditAccountFingerprintReset": "Сброшен отпечаток клиента аккаунта", "auditProxyCreate": "Создан сетевой прокси", "auditProxyUpdate": "Обновленный сетевой прокси", "auditProxyDelete": "Удаленный сетевой прокси", diff --git a/web/src/locales/zh-TW.json b/web/src/locales/zh-TW.json index 2ad33861..ae091bb3 100644 --- a/web/src/locales/zh-TW.json +++ b/web/src/locales/zh-TW.json @@ -599,6 +599,28 @@ "auditAccountConcurrency": "修改帳號並發", "auditAccountResume": "解除帳號冷卻", "auditAccountProxy": "修改帳號網路代理", + "accountFingerprint": "用戶端指紋", + "accountFingerprintHint": "使用此帳號的所有用戶端都會以這組指紋存取上游,不同機器上的用戶端看起來像同一台裝置。留空的欄位會從下一個原生用戶端學習,或使用 SubLane 管理的預設值。", + "fingerprintSourceLearned": "已從原生用戶端學習", + "fingerprintSourceManual": "手動設定", + "fingerprintSourceNone": "尚未學習,下一個原生用戶端會設定它。", + "fingerprintCodexPlatform": "平台與終端", + "fingerprintCodexPlatformHint": "Codex User-Agent 中的平台部分,例如 (Mac OS 15.5.0; arm64) iTerm.app/3.6.1。Codex 版本號由 SubLane 統一管理。", + "fingerprintClaudeOS": "作業系統", + "fingerprintClaudeArch": "架構", + "fingerprintRuntimeVersion": "執行環境版本", + "fingerprintPackageVersion": "SDK 版本", + "fingerprintClaudeHint": "以 X-Stainless 標頭傳送。作業系統和架構從第一個 Claude Code 用戶端學習;執行環境和 SDK 版本跟隨 SubLane 管理的 Claude Code 版本,除非在此手動設定。", + "fingerprintAutomatic": "自動", + "fingerprintDevice": "裝置 ID", + "fingerprintResetDevice": "同時重新產生裝置 ID", + "fingerprintResetDeviceHint": "上游會把此帳號視為在一台新裝置上登入。", + "resetFingerprint": "重設", + "saveFingerprint": "儲存指紋", + "loadingFingerprint": "正在載入指紋…", + "fingerprintUnsupported": "此類帳號沒有用戶端指紋。", + "auditAccountFingerprint": "修改了帳號用戶端指紋", + "auditAccountFingerprintReset": "重設了帳號用戶端指紋", "auditProxyCreate": "建立網路代理", "auditProxyUpdate": "更新網路代理", "auditProxyDelete": "刪除網路代理", diff --git a/web/src/locales/zh.ts b/web/src/locales/zh.ts index b302dc9d..0db4cf1a 100644 --- a/web/src/locales/zh.ts +++ b/web/src/locales/zh.ts @@ -848,6 +848,31 @@ export const zh: Record = { auditAccountConcurrency: '修改账号并发', auditAccountResume: '解除账号冷却', auditAccountProxy: '修改账号网络代理', + accountFingerprint: '客户端指纹', + accountFingerprintHint: + '使用此账号的所有客户端都会以这套指纹访问上游,不同机器上的客户端看起来像同一台设备。留空的字段会从下一个原生客户端学习,或使用 SubLane 管理的默认值。', + fingerprintSourceLearned: '已从原生客户端学习', + fingerprintSourceManual: '手动设置', + fingerprintSourceNone: '尚未学习,下一个原生客户端会设置它。', + fingerprintCodexPlatform: '平台与终端', + fingerprintCodexPlatformHint: + 'Codex User-Agent 中的平台部分,例如 (Mac OS 15.5.0; arm64) iTerm.app/3.6.1。Codex 版本号由 SubLane 统一管理。', + fingerprintClaudeOS: '操作系统', + fingerprintClaudeArch: '架构', + fingerprintRuntimeVersion: '运行时版本', + fingerprintPackageVersion: 'SDK 版本', + fingerprintClaudeHint: + '以 X-Stainless 请求头发送。操作系统和架构从第一个 Claude Code 客户端学习;运行时和 SDK 版本跟随 SubLane 管理的 Claude Code 版本,除非在此手动设置。', + fingerprintAutomatic: '自动', + fingerprintDevice: '设备 ID', + fingerprintResetDevice: '同时重新生成设备 ID', + fingerprintResetDeviceHint: '上游会把此账号视为在一台新设备上登录。', + resetFingerprint: '重置', + saveFingerprint: '保存指纹', + loadingFingerprint: '正在加载指纹…', + fingerprintUnsupported: '此类账号没有客户端指纹。', + auditAccountFingerprint: '修改了账号客户端指纹', + auditAccountFingerprintReset: '重置了账号客户端指纹', auditProxyCreate: '创建网络代理', auditProxyUpdate: '更新网络代理', auditProxyDelete: '删除网络代理', diff --git a/web/src/pages/Accounts.tsx b/web/src/pages/Accounts.tsx index 4b95d502..3564bfda 100644 --- a/web/src/pages/Accounts.tsx +++ b/web/src/pages/Accounts.tsx @@ -3,6 +3,7 @@ import { Link } from '@tanstack/react-router' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Check, + Fingerprint, LoaderCircle, MoreHorizontal, Plus, @@ -18,6 +19,7 @@ import { accountErrorKey, accountOptions, checkAccount, + fingerprintProviders, setAccountEnabled, type Account, providerLabels, @@ -30,6 +32,7 @@ import { DeleteAccount } from '@/components/DeleteAccount' import { Status } from '@/components/Status' import { AccountLimits } from '@/components/AccountLimits' import { AccountProxy } from '@/components/AccountProxy' +import { AccountFingerprint } from '@/components/AccountFingerprint' import { reasonKeys, runtimeOptions } from '@/lib/runtime' import { proxyOptions } from '@/lib/proxies' import { ProviderLogo } from '@/components/ProviderLogo' @@ -57,6 +60,9 @@ export function Accounts() { const [clock, setClock] = useState(() => Date.now()) const [limits, setLimits] = useState(null) const [proxyAccount, setProxyAccount] = useState(null) + const [fingerprintAccount, setFingerprintAccount] = useState( + null, + ) const [connecting, setConnecting] = useState(null) const [removing, setRemoving] = useState(null) const [verified, setVerified] = useState<{ @@ -273,6 +279,14 @@ export function Accounts() {