diff --git a/.downstream/native-domains.md b/.downstream/native-domains.md index 8a940543b523..75dbc31e80f3 100644 --- a/.downstream/native-domains.md +++ b/.downstream/native-domains.md @@ -33,6 +33,15 @@ no additional step-up. Legacy settings APIs use the same keys. setup-token accounts. GET computes effective identity through the forwarding selector, including credential shadows. PUT accepts only `mode`; bulk changes use ordinary account jobs with explicit frozen IDs and only that extra key. +GET and the PUT response also include read-only `device_identity`, +`effective_device` and `identifier_policy`. Saved/seed-derived device fields +are separate from fields parsed from the selected UA; unparseable UA fields +remain null, and disabled simulation has no effective client device. Identifier +rules distinguish fixed values, request-derived handling and passthrough; +request-dependent threads, parent references and window indexes are not +invented from an empty request. Reads never persist identities or timestamps. +The page loads full identities automatically, limits detail reads to three, +rejects obsolete page/policy/mode responses, and retains unsaved mode drafts. General account edits preserve the latest stored mode when it is omitted. Existing seeds and persisted TUI identities are retained, and new accounts still default to `device`. diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 3b5f0b3ee517..467d62c64e55 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -39,7 +39,7 @@ jobs: cache-dependency-path: backend/go.sum - name: Verify Go version run: | - go version | grep -q 'go1.27.0' + go version | grep -q 'go1.27.2' - name: Verify Go modules working-directory: backend run: go mod verify @@ -85,11 +85,11 @@ jobs: cache-dependency-path: backend/go.sum - name: Verify Go version run: | - go version | grep -q 'go1.27.0' + go version | grep -q 'go1.27.2' - name: golangci-lint uses: golangci/golangci-lint-action@v9 with: - version: v2.13 + version: v2.14.0 args: --timeout=30m working-directory: backend diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 34563cb681b6..7b9ab16f35ed 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -139,7 +139,7 @@ jobs: check-latest: false cache: false - name: Verify Go version - run: go version | grep -q 'go1.27.0' + run: go version | grep -q 'go1.27.2' - name: Restore target-specific Go cache id: go-cache uses: actions/cache/restore@v6 diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 62992815411f..745ad5b6fea1 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -26,7 +26,7 @@ jobs: cache-dependency-path: backend/go.sum - name: Verify Go version run: | - go version | grep -q 'go1.27.0' + go version | grep -q 'go1.27.2' - name: Run govulncheck working-directory: backend run: | diff --git a/DEV_GUIDE.md b/DEV_GUIDE.md index 2f440d0f6230..299115393da0 100644 --- a/DEV_GUIDE.md +++ b/DEV_GUIDE.md @@ -53,7 +53,7 @@ npm install -g pnpm ### CI 要求 -- Go 版本必须是 **1.27.0**:三个 workflow 都用 `go-version-file: backend/go.mod` 取版本,随后硬断言 `go version | grep -q 'go1.27.0'`。升级 Go 时要同时改 `backend/go.mod`、`backend-ci.yml`(两处)、`release.yml`、`security-scan.yml` 里的这句断言,**以及三个 Dockerfile 里的 Go 构建镜像**(`Dockerfile` / `deploy/Dockerfile` 的 `ARG GOLANG_IMAGE`、`backend/Dockerfile` 的 `FROM golang:`)。前者漏了 CI 会在版本校验步骤直接失败;**后者漏了 CI 不会报,而是等到有人用这些 Dockerfile 构建时才失败**(`go.mod requires go >= X (running Y; GOTOOLCHAIN=local)`)。 +- Go 版本必须是 **1.27.2**:三个 workflow 都用 `go-version-file: backend/go.mod` 取版本,随后硬断言 `go version | grep -q 'go1.27.2'`。升级 Go 时要同时改 `backend/go.mod`、`backend-ci.yml`(两处)、`release.yml`、`security-scan.yml` 里的这句断言,**以及三个 Dockerfile 里的 Go 构建镜像**(`Dockerfile` / `deploy/Dockerfile` 的 `ARG GOLANG_IMAGE`、`backend/Dockerfile` 的 `FROM golang:`)。前者漏了 CI 会在版本校验步骤直接失败;**后者漏了 CI 不会报,而是等到有人用这些 Dockerfile 构建时才失败**(`go.mod requires go >= X (running Y; GOTOOLCHAIN=local)`)。 - 前端使用 `pnpm install --frozen-lockfile`,必须提交 `pnpm-lock.yaml` ### 本地测试命令 diff --git a/Dockerfile b/Dockerfile index 409cddf2278c..0a1138fe99a7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ # ============================================================================= ARG NODE_IMAGE=node:24-alpine -ARG GOLANG_IMAGE=golang:1.27.0-alpine +ARG GOLANG_IMAGE=golang:1.27.2-alpine ARG ALPINE_IMAGE=alpine:3.21 ARG POSTGRES_IMAGE=postgres:18-alpine ARG GOPROXY=https://goproxy.cn,direct diff --git a/README.md b/README.md index 1e8ea3f0beed..6f8e9e14ad54 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # Sub2API -[![Go](https://img.shields.io/badge/Go-1.27.0-00ADD8.svg)](https://golang.org/) +[![Go](https://img.shields.io/badge/Go-1.27.2-00ADD8.svg)](https://golang.org/) [![Vue](https://img.shields.io/badge/Vue-3.4+-4FC08D.svg)](https://vuejs.org/) [![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15+-336791.svg)](https://www.postgresql.org/) [![Redis](https://img.shields.io/badge/Redis-7+-DC382D.svg)](https://redis.io/) @@ -200,7 +200,7 @@ Community projects that extend or integrate with Sub2API: | Component | Technology | |-----------|------------| -| Backend | Go 1.27.0, Gin, Ent | +| Backend | Go 1.27.2, Gin, Ent | | Frontend | Vue 3.4+, Vite 5+, TailwindCSS | | Database | PostgreSQL 15+ | | Cache/Queue | Redis 7+ | diff --git a/README_CN.md b/README_CN.md index 57d92ca92a2f..765be58582bd 100644 --- a/README_CN.md +++ b/README_CN.md @@ -4,7 +4,7 @@ # Sub2API -[![Go](https://img.shields.io/badge/Go-1.27.0-00ADD8.svg)](https://golang.org/) +[![Go](https://img.shields.io/badge/Go-1.27.2-00ADD8.svg)](https://golang.org/) [![Vue](https://img.shields.io/badge/Vue-3.4+-4FC08D.svg)](https://vuejs.org/) [![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15+-336791.svg)](https://www.postgresql.org/) [![Redis](https://img.shields.io/badge/Redis-7+-DC382D.svg)](https://redis.io/) @@ -202,7 +202,7 @@ Sub2API 是一个 AI API 网关平台,用于分发和管理 AI 产品订阅的 | 组件 | 技术 | |------|------| -| 后端 | Go 1.27.0, Gin, Ent | +| 后端 | Go 1.27.2, Gin, Ent | | 前端 | Vue 3.4+, Vite 5+, TailwindCSS | | 数据库 | PostgreSQL 15+ | | 缓存/队列 | Redis 7+ | diff --git a/README_JA.md b/README_JA.md index 24ddd4dd6309..06d399d09d8c 100644 --- a/README_JA.md +++ b/README_JA.md @@ -4,7 +4,7 @@ # Sub2API -[![Go](https://img.shields.io/badge/Go-1.27.0-00ADD8.svg)](https://golang.org/) +[![Go](https://img.shields.io/badge/Go-1.27.2-00ADD8.svg)](https://golang.org/) [![Vue](https://img.shields.io/badge/Vue-3.4+-4FC08D.svg)](https://vuejs.org/) [![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15+-336791.svg)](https://www.postgresql.org/) [![Redis](https://img.shields.io/badge/Redis-7+-DC382D.svg)](https://redis.io/) @@ -201,7 +201,7 @@ Sub2API を拡張・統合するコミュニティプロジェクト: | コンポーネント | 技術 | |-----------|------------| -| バックエンド | Go 1.27.0, Gin, Ent | +| バックエンド | Go 1.27.2, Gin, Ent | | フロントエンド | Vue 3.4+, Vite 5+, TailwindCSS | | データベース | PostgreSQL 15+ | | キャッシュ/キュー | Redis 7+ | diff --git a/backend/.golangci.yml b/backend/.golangci.yml index 12363ecf145a..275afb97f3d9 100644 --- a/backend/.golangci.yml +++ b/backend/.golangci.yml @@ -127,6 +127,20 @@ linters: exclusions: rules: + # x/net v0.60 marks the Go 1.27 HTTP/2 bridge deprecated. Keep these + # specific compatibility calls: migrating the independently configured + # H2C idle timeout or existing transport registration is outside this + # security patch. Only SA1019 on the listed symbols/paths is exempt; + # type checking, security checks and all other staticcheck diagnostics run. + - path: '^internal/repository/http_upstream(_http2_keepalive_test)?\.go$' + linters: [ staticcheck ] + text: '^SA1019: (golang\.org/x/net/http2\.(Transport|ConfigureTransports)|\(golang\.org/x/net/http2\.Transport\)\.(ReadIdleTimeout|PingTimeout)) is deprecated:' + - path: '^internal/server/http\.go$' + linters: [ staticcheck ] + text: '^SA1019: (golang\.org/x/net/http2\.(ConfigureServer|Server)|\(golang\.org/x/net/http2\.Server\)\.(MaxConcurrentStreams|IdleTimeout|MaxReadFrameSize|MaxUploadBufferPerConnection|MaxUploadBufferPerStream)) is deprecated:' + - path: '^internal/service/openai_codex_models_service(_test)?\.go$' + linters: [ staticcheck ] + text: '^SA1019: golang\.org/x/net/http2\.GoAwayError is deprecated:' # G703/G704 污点分析在测试文件中只会命中 httptest mock 与测试者自设的 # 环境变量路径,不构成攻击面;且该分析跨环境结果不稳定(本地/CI 报告的 # 位置集合不同),逐点 nolint 无法收敛,故按路径豁免。生产代码不豁免, diff --git a/backend/Dockerfile b/backend/Dockerfile index 398d9bdfcfe7..40e228cd372d 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.27.0-alpine +FROM golang:1.27.2-alpine WORKDIR /app diff --git a/backend/go.mod b/backend/go.mod index 0ce1a163b854..73c1ad07c73e 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,6 +1,6 @@ module github.com/Wei-Shaw/sub2api -go 1.27.0 +go 1.27.2 require ( entgo.io/ent v0.14.5 @@ -51,13 +51,13 @@ require ( github.com/wechatpay-apiv3/wechatpay-go v0.2.21 github.com/zeromicro/go-zero v1.9.4 go.uber.org/zap v1.24.0 - golang.org/x/crypto v0.55.0 + golang.org/x/crypto v0.57.0 golang.org/x/image v0.41.0 - golang.org/x/mod v0.38.0 - golang.org/x/net v0.58.0 - golang.org/x/sync v0.22.0 - golang.org/x/term v0.45.0 - golang.org/x/text v0.41.0 + golang.org/x/mod v0.41.0 + golang.org/x/net v0.60.0 + golang.org/x/sync v0.23.0 + golang.org/x/term v0.46.0 + golang.org/x/text v0.42.0 google.golang.org/grpc v1.83.2 google.golang.org/protobuf v1.36.11 gopkg.in/natefinch/lumberjack.v2 v2.2.1 @@ -206,9 +206,9 @@ require ( go.uber.org/multierr v1.9.0 // indirect golang.org/x/arch v0.3.0 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect - golang.org/x/sys v0.47.0 // indirect + golang.org/x/sys v0.48.0 // indirect golang.org/x/time v0.12.0 // indirect - golang.org/x/tools v0.48.0 // indirect + golang.org/x/tools v0.50.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect gopkg.in/ini.v1 v1.67.0 // indirect modernc.org/libc v1.67.6 // indirect diff --git a/backend/go.sum b/backend/go.sum index 7e445c134f87..363311a9e1ae 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -566,6 +566,8 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= @@ -582,6 +584,8 @@ golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -605,6 +609,8 @@ golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= +golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -618,6 +624,8 @@ golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -651,9 +659,13 @@ golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959 h1:RJhm5l6Fo4rmEIcndxDllNhhf/fAx8qIm4t6A7vpm2A= golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959/go.mod h1:LV7u5Oco+Z/g6XI7PqN+EUUUGGkEcmB1uj2ceI0fOVg= +golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 h1:ZUSxONxc981v7AW7QUg+I9WwZzSTTJ019ENBYr5pV/Q= +golang.org/x/telemetry v0.0.0-20260908163034-4bcc4b2ee518 h1:F5BWKvW126NXR74uxkxuc1jQHhm/rwm/J3rSiFyuRs4= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -667,6 +679,8 @@ golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -679,6 +693,8 @@ golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -695,6 +711,10 @@ golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58 golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= +golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU= +golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/backend/internal/handler/admin/codex_fingerprint_handler_test.go b/backend/internal/handler/admin/codex_fingerprint_handler_test.go index 6e1adb9706a0..c0c60ea1960c 100644 --- a/backend/internal/handler/admin/codex_fingerprint_handler_test.go +++ b/backend/internal/handler/admin/codex_fingerprint_handler_test.go @@ -51,12 +51,17 @@ func TestCodexFingerprintAccountHandlerOnlyChangesModeAndUsesParentIdentity(t *t read := call(http.MethodGet, "") require.Equal(t, http.StatusOK, read.Code) require.Equal(t, int64(1), gjson.Get(read.Body.String(), "data.identity.identity_account_id").Int()) + require.NotEmpty(t, gjson.Get(read.Body.String(), "data.device_identity.os_type").String()) + require.False(t, gjson.Get(read.Body.String(), "data.identity.identity_persisted").Bool()) + require.Empty(t, gjson.Get(read.Body.String(), "data.device_identity.generated_at").String()) + require.True(t, gjson.Get(read.Body.String(), "data.identifier_policy").Exists()) + require.Nil(t, admin.updates, "GET must not write derived identities") saved := call(http.MethodPut, `{"mode":"off"}`) require.Equal(t, http.StatusOK, saved.Code) require.Equal(t, map[string]any{"codex_fingerprint_mode": "off"}, admin.updates) require.Equal(t, "value", shadow.Extra["preserved"]) require.Equal(t, "device", parent.Extra["codex_fingerprint_mode"]) - for _, body := range []string{`{"mode":"invalid"}`, `{"mode":"full","codex_fingerprint_seed":"injected"}`, `{"mode":null}`} { + for _, body := range []string{`{"mode":"invalid"}`, `{"mode":"full","codex_fingerprint_seed":"injected"}`, `{"mode":"full","device_identity":{"os_type":"Windows"}}`, `{"mode":null}`} { rejected := call(http.MethodPut, body) require.Equal(t, http.StatusBadRequest, rejected.Code) require.Equal(t, "off", shadow.Extra["codex_fingerprint_mode"]) diff --git a/backend/internal/repository/http_upstream.go b/backend/internal/repository/http_upstream.go index 771c0c72adc0..57cb55441ce6 100644 --- a/backend/internal/repository/http_upstream.go +++ b/backend/internal/repository/http_upstream.go @@ -1466,16 +1466,23 @@ func isOpenAIUpstreamProtocolMode(mode string) bool { // Go 默认惰性配置 http2 且 ReadIdleTimeout=0(不发健康 PING),无法检测被代理/NAT // 静默掐断的死连接。此处主动设置 ReadIdleTimeout/PingTimeout,让死连接被提前 PING // 出并关闭,请求得以重建连接而非挂到 TCP 重传超时。返回底层 *http2.Transport 便于测试。 +// +//lint:ignore SA1019 Retain the configured x/net transport handle to preserve existing pool and proxy behavior. func enableHTTP2KeepAlive(transport *http.Transport, protocolMode string) (*http2.Transport, error) { + //lint:ignore SA1019 Keep the existing transport registration; this release only patches its security dependencies. h2, err := http2.ConfigureTransports(transport) if err != nil { return nil, err } if h2 != nil { + //lint:ignore SA1019 Preserve the existing long-stream health-check configuration on the compatibility handle. h2.ReadIdleTimeout = longStreamHTTP2ReadIdleTimeout + //lint:ignore SA1019 Preserve the separate long-stream PING response deadline. h2.PingTimeout = longStreamHTTP2PingTimeout if protocolMode == upstreamProtocolModeOpenAIH2 { + //lint:ignore SA1019 Preserve the existing OpenAI health-check configuration on the compatibility handle. h2.ReadIdleTimeout = openAIHTTP2ReadIdleTimeout + //lint:ignore SA1019 Preserve the separate OpenAI PING response deadline. h2.PingTimeout = openAIHTTP2PingTimeout } } diff --git a/backend/internal/repository/http_upstream_http2_keepalive_test.go b/backend/internal/repository/http_upstream_http2_keepalive_test.go index 249f246ff891..25c503821daa 100644 --- a/backend/internal/repository/http_upstream_http2_keepalive_test.go +++ b/backend/internal/repository/http_upstream_http2_keepalive_test.go @@ -52,7 +52,9 @@ func TestEnableHTTP2KeepAlive_EnablesPingHealthCheck(t *testing.T) { h2, err := enableHTTP2KeepAlive(tr, tc.mode) require.NoError(t, err) require.NotNil(t, h2, "必须返回已配置的 *http2.Transport") + //lint:ignore SA1019 Assert the preserved compatibility-handle health-check contract. require.Equal(t, tc.readIdleTimeout, h2.ReadIdleTimeout) + //lint:ignore SA1019 Assert the preserved independent PING response deadline. require.Equal(t, tc.pingTimeout, h2.PingTimeout, "各模式应使用独立的 PING 应答期限") requireHTTP2Configured(t, tr, "http2 必须已挂到底层 http.Transport 上") }) diff --git a/backend/internal/server/http.go b/backend/internal/server/http.go index f999b343b213..e10fe2ad011d 100644 --- a/backend/internal/server/http.go +++ b/backend/internal/server/http.go @@ -136,12 +136,18 @@ func ProvideHTTPServer(cfg *config.Config, router *gin.Engine) *http.Server { // 根据配置决定是否启用 H2C if cfg.Server.H2C.Enabled { h2cConfig := cfg.Server.H2C + //lint:ignore SA1019 Retain the existing H2C configuration bridge and its independently configured idle timeout. if err := http2.ConfigureServer(server, &http2.Server{ - MaxConcurrentStreams: h2cConfig.MaxConcurrentStreams, - IdleTimeout: time.Duration(h2cConfig.IdleTimeout) * time.Second, - MaxReadFrameSize: uint32(h2cConfig.MaxReadFrameSize), + //lint:ignore SA1019 Preserve the current H2C stream limit through the compatibility bridge. + MaxConcurrentStreams: h2cConfig.MaxConcurrentStreams, + //lint:ignore SA1019 The H2C idle timeout is independent of the HTTP/1 server idle timeout. + IdleTimeout: time.Duration(h2cConfig.IdleTimeout) * time.Second, + //lint:ignore SA1019 Preserve the current H2C frame-size limit through the compatibility bridge. + MaxReadFrameSize: uint32(h2cConfig.MaxReadFrameSize), + //lint:ignore SA1019 Preserve the current H2C connection receive window. MaxUploadBufferPerConnection: int32(h2cConfig.MaxUploadBufferPerConnection), - MaxUploadBufferPerStream: int32(h2cConfig.MaxUploadBufferPerStream), + //lint:ignore SA1019 Preserve the current H2C stream receive window. + MaxUploadBufferPerStream: int32(h2cConfig.MaxUploadBufferPerStream), }); err != nil { log.Printf("Failed to configure HTTP/2 Cleartext (h2c): %v", err) } else { diff --git a/backend/internal/service/codex_fingerprint_account_view.go b/backend/internal/service/codex_fingerprint_account_view.go new file mode 100644 index 000000000000..ea3c4a749abd --- /dev/null +++ b/backend/internal/service/codex_fingerprint_account_view.go @@ -0,0 +1,126 @@ +package service + +import ( + "regexp" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" +) + +// These are read-only descriptions of the existing policy, not wire captures. +type CodexFingerprintAccountView struct { + Mode string `json:"mode"` + Identity codexIdentitySnapshot `json:"identity"` + SimulationEnabled bool `json:"simulation_enabled"` + DeviceIdentity *codexClientIdentity `json:"device_identity"` + EffectiveDevice *CodexFingerprintEffectiveDevice `json:"effective_device"` + IdentifierPolicy CodexFingerprintIdentifierPolicy `json:"identifier_policy"` +} + +type CodexFingerprintEffectiveDevice struct { + OSType *string `json:"os_type"` + OSVersion *string `json:"os_version"` + Arch *string `json:"arch"` + Terminal *string `json:"terminal"` + PlatformSandbox *string `json:"platform_sandbox"` +} + +type CodexFingerprintIdentifierRule struct { + Behavior string `json:"behavior"` + Rule string `json:"rule"` + Value *string `json:"value"` +} + +type CodexFingerprintIdentifierPolicy struct { + InstallationID CodexFingerprintIdentifierRule `json:"installation_id"` + SessionID CodexFingerprintIdentifierRule `json:"session_id"` + ThreadID CodexFingerprintIdentifierRule `json:"thread_id"` + ParentThreadID CodexFingerprintIdentifierRule `json:"parent_thread_id"` + WindowID CodexFingerprintIdentifierRule `json:"window_id"` +} + +func DescribeCodexFingerprintAccount(routed, source *Account) CodexFingerprintAccountView { + if source == nil { + source = routed + } + policy := currentCodexFingerprintPolicy() + view := CodexFingerprintAccountView{ + Mode: string(routed.GetCodexFingerprintMode()), + Identity: resolveCodexIdentitySnapshot(routed, source, codexAccountIdentityOverrideUA(source), policy), + SimulationEnabled: policy.enabled, + } + if device, ok := source.CodexClientIdentity(); ok { + view.DeviceIdentity = &device + } + if policy.enabled { + view.EffectiveDevice = describeCodexFingerprintEffectiveDevice(view.Identity.UserAgent) + } + view.IdentifierPolicy = describeCodexFingerprintIdentifierPolicy(source, view.Identity.FingerprintModeEffective) + return view +} + +var codexFingerprintDeviceUAPattern = regexp.MustCompile(`^.+?/\S+\s+\(([^;()]+);\s*([^()]+)\)(?:\s+(.*))?$`) +var codexFingerprintOSVersionPattern = regexp.MustCompile(`^[0-9]+(?:\.[0-9]+)*$`) + +func codexFingerprintOptionalString(value string) *string { + value = strings.TrimSpace(value) + if value == "" { + return nil + } + return &value +} + +// Only parse fields actually present in the selected UA. A valid custom Codex +// UA need not carry device fields, so missing fields must remain unknown. +func describeCodexFingerprintEffectiveDevice(userAgent string) *CodexFingerprintEffectiveDevice { + device := &CodexFingerprintEffectiveDevice{} + parts := codexFingerprintDeviceUAPattern.FindStringSubmatch(userAgent) + if len(parts) == 0 { + return device + } + os := strings.TrimSpace(parts[1]) + if split := strings.LastIndexByte(os, ' '); split > 0 && codexFingerprintOSVersionPattern.MatchString(os[split+1:]) { + device.OSVersion = codexFingerprintOptionalString(os[split+1:]) + os = os[:split] + } + device.OSType = codexFingerprintOptionalString(os) + device.Arch = codexFingerprintOptionalString(parts[2]) + terminal := strings.TrimSpace(parts[3]) + if split := strings.LastIndex(terminal, " ("); split >= 0 && strings.HasSuffix(terminal, ")") { + trailer := strings.TrimSuffix(terminal[split+2:], ")") + if name, _, ok := strings.Cut(trailer, ";"); ok && openai.IsCodexOfficialClientOriginator(strings.TrimSpace(name)) { + terminal = terminal[:split] + } + } + device.Terminal = codexFingerprintOptionalString(terminal) + device.PlatformSandbox = codexFingerprintOptionalString(codexSandboxForUserAgent(userAgent)) + return device +} + +func describeCodexFingerprintIdentifierPolicy(source *Account, effectiveMode string) CodexFingerprintIdentifierPolicy { + preserve := CodexFingerprintIdentifierRule{Behavior: "passthrough", Rule: "preserve_client"} + result := CodexFingerprintIdentifierPolicy{preserve, preserve, preserve, preserve, preserve} + mode := codexFingerprintMode(effectiveMode) + if source == nil || mode == codexFingerprintOff { + return result + } + seed, ok := codexFingerprintSeed(source.Extra) + if !ok { + return result + } + result.InstallationID = CodexFingerprintIdentifierRule{Behavior: "fixed", Rule: "account_device", Value: codexFingerprintOptionalString(resolveConvergedInstallationID(source, seed))} + if mode == codexFingerprintDevice { + return result + } + sessionID := resolveConvergedSessionID(seed) + result.SessionID = CodexFingerprintIdentifierRule{Behavior: "fixed", Rule: "account_session", Value: &sessionID} + result.ThreadID = CodexFingerprintIdentifierRule{Behavior: "request_derived", Rule: "mapped_thread"} + result.ParentThreadID = CodexFingerprintIdentifierRule{Behavior: "request_derived", Rule: "mapped_parent_thread"} + result.WindowID = CodexFingerprintIdentifierRule{Behavior: "request_derived", Rule: "mapped_window"} + if mode == codexFingerprintFull { + result.ThreadID = CodexFingerprintIdentifierRule{Behavior: "fixed", Rule: "account_thread", Value: &sessionID} + result.ParentThreadID.Rule = "remove_parent_self_reference" + result.WindowID.Rule = "merged_window" + } + return result +} diff --git a/backend/internal/service/codex_fingerprint_account_view_test.go b/backend/internal/service/codex_fingerprint_account_view_test.go new file mode 100644 index 000000000000..d64e26a7fdc8 --- /dev/null +++ b/backend/internal/service/codex_fingerprint_account_view_test.go @@ -0,0 +1,140 @@ +package service + +import ( + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func fingerprintViewTestPolicy(t *testing.T, enabled bool) { + t.Helper() + fingerprintPolicyTestState(t) + previous := codexForceCLI.Load() + t.Cleanup(func() { codexForceCLI.Store(previous) }) + codexForceCLI.Store(false) + publishedCodexFingerprintPolicy.Store(fingerprintTestPolicy(enabled, "0.162.0")) +} + +func fingerprintViewStoredAccount(mode string) *Account { + account := fingerprintTestAccount(mode) + device := codexClientIdentity{Version: 1, OSType: "Windows", OSVersion: "10.0.26100", Arch: "x86_64", Terminal: "vscode/1.104.0", Sandbox: "windows_sandbox"} + account.Extra[CodexClientIdentityExtraKey] = codexClientIdentityExtraValue(device, time.Date(2026, 10, 8, 8, 0, 0, 0, time.UTC)) + return account +} + +func TestCodexFingerprintAccountViewReadsStoredAndDerivedIdentitiesWithoutWriting(t *testing.T) { + fingerprintViewTestPolicy(t, true) + for _, stored := range []bool{true, false} { + account := fingerprintViewStoredAccount("device") + if !stored { + delete(account.Extra, CodexClientIdentityExtraKey) + } + before, err := json.Marshal(account.Extra) + require.NoError(t, err) + view := DescribeCodexFingerprintAccount(account, account) + require.NotNil(t, view.DeviceIdentity) + require.Equal(t, stored, view.Identity.IdentityPersisted) + require.Equal(t, view.DeviceIdentity.OSType, *view.EffectiveDevice.OSType) + require.Equal(t, view.DeviceIdentity.Arch, *view.EffectiveDevice.Arch) + require.Equal(t, view.DeviceIdentity.Terminal, *view.EffectiveDevice.Terminal) + if stored { + require.Equal(t, "2026-10-08T08:00:00Z", view.DeviceIdentity.GeneratedAt) + } else { + require.Empty(t, view.DeviceIdentity.GeneratedAt, "a read must not manufacture a generation time") + } + after, err := json.Marshal(account.Extra) + require.NoError(t, err) + require.JSONEq(t, string(before), string(after)) + } +} + +func TestCodexFingerprintAccountViewMissingIdentityAndMasterOff(t *testing.T) { + fingerprintViewTestPolicy(t, true) + account := fingerprintTestAccount("full") + delete(account.Extra, codexFingerprintSeedExtraKey) + view := DescribeCodexFingerprintAccount(account, account) + require.Nil(t, view.DeviceIdentity) + require.False(t, view.Identity.IdentityPersisted) + require.Equal(t, "seed_missing", view.Identity.FingerprintReason) + require.Equal(t, "passthrough", view.IdentifierPolicy.InstallationID.Behavior) + require.Nil(t, view.IdentifierPolicy.InstallationID.Value) + account = fingerprintViewStoredAccount("full") + publishedCodexFingerprintPolicy.Store(fingerprintTestPolicy(false, "0.162.0")) + view = DescribeCodexFingerprintAccount(account, account) + require.NotNil(t, view.DeviceIdentity, "disabled simulation must keep the saved identity visible") + require.Nil(t, view.EffectiveDevice, "protocol fallback is not the real client device") + require.Equal(t, "protocol_fallback", view.Identity.IdentitySource) + require.Equal(t, "simulation_disabled", view.Identity.FingerprintReason) + require.Equal(t, "passthrough", view.IdentifierPolicy.SessionID.Behavior) + require.Nil(t, view.IdentifierPolicy.ThreadID.Value) +} + +func TestCodexFingerprintAccountViewCustomUAAndUnknownDeviceFields(t *testing.T) { + fingerprintViewTestPolicy(t, true) + account := fingerprintViewStoredAccount("device") + account.Credentials = map[string]any{"user_agent": "codex-tui/0.150.0 (Mac OS X 14.0; arm64) iTerm.app/3.5.15 (codex-tui; 0.150.0)"} + view := DescribeCodexFingerprintAccount(account, account) + require.Equal(t, "override_ua", view.Identity.IdentitySource) + require.Equal(t, "Windows", view.DeviceIdentity.OSType) + require.Equal(t, "Mac OS X", *view.EffectiveDevice.OSType) + require.Equal(t, "14.0", *view.EffectiveDevice.OSVersion) + require.Equal(t, "arm64", *view.EffectiveDevice.Arch) + require.Equal(t, "iTerm.app/3.5.15", *view.EffectiveDevice.Terminal) + require.Equal(t, "seatbelt", *view.EffectiveDevice.PlatformSandbox) + require.Contains(t, view.Identity.UserAgent, "0.162.0") + account.Credentials["user_agent"] = "codex-tui/0.150.0" + view = DescribeCodexFingerprintAccount(account, account) + require.Equal(t, "codex-tui/0.162.0", view.Identity.UserAgent) + require.NotNil(t, view.EffectiveDevice) + require.Nil(t, view.EffectiveDevice.OSType) + require.Nil(t, view.EffectiveDevice.Terminal) + codexForceCLI.Store(true) + view = DescribeCodexFingerprintAccount(account, account) + require.Equal(t, "account", view.Identity.IdentitySource) + require.Equal(t, "Windows", *view.EffectiveDevice.OSType) +} + +func TestCodexFingerprintAccountViewPoliciesMatchSharedForwardingAndParentSource(t *testing.T) { + fingerprintViewTestPolicy(t, true) + parent := fingerprintViewStoredAccount("device") + parent.Extra["openai_device_id"] = "43ef16f3-2c23-4ca0-b67d-7c3f0189aca4" + child := fingerprintTestAccount("device") + child.ID = 84 + child.ParentAccountID = &parent.ID + for _, mode := range []string{"off", "device", "session", "full"} { + t.Run(mode, func(t *testing.T) { + child.Extra[codexFingerprintModeExtraKey] = mode + view := DescribeCodexFingerprintAccount(child, parent) + require.Equal(t, parent.ID, view.Identity.IdentityAccountID) + require.Equal(t, "Windows", view.DeviceIdentity.OSType) + require.Equal(t, mode, view.Mode) + headers := http.Header{"Session-Id": {"real-session"}, "Thread-Id": {"real-thread"}, "X-Codex-Window-Id": {"real-thread:9"}} + ids := resolveCodexFingerprintIDsWithSource(child, parent, headers, currentCodexFingerprintPolicy()) + if mode == "off" { + require.Nil(t, ids) + require.Equal(t, "passthrough", view.IdentifierPolicy.InstallationID.Behavior) + require.Nil(t, view.IdentifierPolicy.InstallationID.Value) + return + } + require.Equal(t, ids.installationID, *view.IdentifierPolicy.InstallationID.Value) + require.Equal(t, "43ef16f3-2c23-4ca0-b67d-7c3f0189aca4", *view.IdentifierPolicy.InstallationID.Value) + if mode == "device" { + require.Equal(t, "passthrough", view.IdentifierPolicy.SessionID.Behavior) + return + } + require.Equal(t, ids.sessionID, *view.IdentifierPolicy.SessionID.Value) + require.Nil(t, view.IdentifierPolicy.WindowID.Value, "the real request window index is not known by this endpoint") + if mode == "session" { + require.Equal(t, "request_derived", view.IdentifierPolicy.ThreadID.Behavior) + require.Nil(t, view.IdentifierPolicy.ThreadID.Value, "do not display the empty-request thread fallback") + require.Equal(t, "mapped_parent_thread", view.IdentifierPolicy.ParentThreadID.Rule) + } else { + require.Equal(t, ids.threadID, *view.IdentifierPolicy.ThreadID.Value) + require.Equal(t, "remove_parent_self_reference", view.IdentifierPolicy.ParentThreadID.Rule) + } + }) + } +} diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index 4a71ffbd6a0b..c1288e35ad80 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -1573,6 +1573,7 @@ func isRetryableCodexModelsManifestTransportError(err error) bool { if errors.As(err, &dnsErr) { return true } + //lint:ignore SA1019 Compatibility with GOAWAY errors returned by existing x/net-based clients is still required. var goAwayErr http2.GoAwayError if errors.As(err, &goAwayErr) { return true diff --git a/backend/internal/service/openai_codex_models_service_test.go b/backend/internal/service/openai_codex_models_service_test.go index bb6f289dc88a..3352ff0752e0 100644 --- a/backend/internal/service/openai_codex_models_service_test.go +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -1796,7 +1796,8 @@ func TestIsRetryableCodexModelsManifestTransportError(t *testing.T) { retryable: true, }, { - name: "typed HTTP2 GOAWAY", + name: "typed HTTP2 GOAWAY", + //lint:ignore SA1019 Keep regression coverage for GOAWAY errors from existing x/net-based clients. err: http2.GoAwayError{ErrCode: http2.ErrCodeNo}, retryable: true, }, diff --git a/backend/internal/service/setting_codex_fingerprint.go b/backend/internal/service/setting_codex_fingerprint.go index dde3366e6bf8..bd0d62fd258c 100644 --- a/backend/internal/service/setting_codex_fingerprint.go +++ b/backend/internal/service/setting_codex_fingerprint.go @@ -248,22 +248,6 @@ func (s *SettingService) UpdateCodexFingerprintSettings(ctx context.Context, con return view, nil } -// CodexFingerprintAccountView uses the same identity selector as forwarding. -type CodexFingerprintAccountView struct { - Mode string `json:"mode"` - Identity codexIdentitySnapshot `json:"identity"` - SimulationEnabled bool `json:"simulation_enabled"` -} - -func DescribeCodexFingerprintAccount(routed, source *Account) CodexFingerprintAccountView { - policy := currentCodexFingerprintPolicy() - return CodexFingerprintAccountView{ - Mode: string(routed.GetCodexFingerprintMode()), - Identity: resolveCodexIdentitySnapshot(routed, source, codexAccountIdentityOverrideUA(source), policy), - SimulationEnabled: policy.enabled, - } -} - func DecodeCodexFingerprintMode(raw []byte) (string, error) { var config struct { Mode string `json:"mode"` diff --git a/deploy/Dockerfile b/deploy/Dockerfile index b9fe1a2d0cec..4ce903507ffd 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -7,7 +7,7 @@ # ============================================================================= ARG NODE_IMAGE=node:24-alpine -ARG GOLANG_IMAGE=golang:1.27.0-alpine +ARG GOLANG_IMAGE=golang:1.27.2-alpine ARG ALPINE_IMAGE=alpine:3.20 ARG GOPROXY=https://goproxy.cn,direct ARG GOSUMDB=sum.golang.google.cn diff --git a/frontend/src/api/admin/codexFingerprint.ts b/frontend/src/api/admin/codexFingerprint.ts index af90affef771..682679078f4b 100644 --- a/frontend/src/api/admin/codexFingerprint.ts +++ b/frontend/src/api/admin/codexFingerprint.ts @@ -24,9 +24,32 @@ export interface CodexFingerprintAccountView { version: string identity_source: string identity_account_id: number + identity_persisted: boolean + fingerprint_mode_configured: string fingerprint_mode_effective: CodexFingerprintMode fingerprint_reason: string } + device_identity: { + v: number + os_type: string + os_version: string + arch: string + terminal: string + sandbox: string + generated_at?: string + } | null + effective_device: { + os_type: string | null + os_version: string | null + arch: string | null + terminal: string | null + platform_sandbox: string | null + } | null + identifier_policy: Record<'installation_id' | 'session_id' | 'thread_id' | 'parent_thread_id' | 'window_id', { + behavior: 'fixed' | 'request_derived' | 'passthrough' + rule: string + value: string | null + }> } export const codexFingerprintAPI = { @@ -40,8 +63,8 @@ export const codexFingerprintAPI = { const { data } = await apiClient.put('/admin/settings/codex-fingerprint', config) return data }, - async getAccount(id: number): Promise { - const { data } = await apiClient.get(`/admin/accounts/${id}/codex-fingerprint`) + async getAccount(id: number, options?: { signal?: AbortSignal }): Promise { + const { data } = await apiClient.get(`/admin/accounts/${id}/codex-fingerprint`, options) return data }, async saveAccount(id: number, mode: CodexFingerprintMode): Promise { diff --git a/frontend/src/components/admin/CodexFingerprintIdentity.vue b/frontend/src/components/admin/CodexFingerprintIdentity.vue new file mode 100644 index 000000000000..12d35792eb0c --- /dev/null +++ b/frontend/src/components/admin/CodexFingerprintIdentity.vue @@ -0,0 +1,111 @@ + + + diff --git a/frontend/src/views/admin/CodexFingerprintView.vue b/frontend/src/views/admin/CodexFingerprintView.vue index 24fb1a90d476..e59320c66a6d 100644 --- a/frontend/src/views/admin/CodexFingerprintView.vue +++ b/frontend/src/views/admin/CodexFingerprintView.vue @@ -2,8 +2,8 @@
-

{{ text('Codex 指纹', 'Codex fingerprint') }}

-

{{ text('统一管理 OpenAI OAuth 和 Setup Token 账号的 Codex TUI 身份及设备、会话标识。', 'Manage the Codex TUI identity and device/session identifiers of OpenAI OAuth and setup-token accounts.') }}

+

{{ text('Codex 指纹模拟', 'Codex fingerprint simulation') }}

+

{{ text('统一管理指纹模拟并查看完整固定设备身份与当前策略。账号列表仅包含 OpenAI OAuth 和 Setup Token 账号;设备身份由后台自动生成并保留。', 'Manage fingerprint simulation and inspect complete fixed device identities and current policies. The list includes only OpenAI OAuth and setup-token accounts; device identities are generated and preserved by the backend.') }}

{{ message }}

@@ -68,14 +68,13 @@ {{ account.name }}#{{ account.id }} · {{ account.type }}{{ account.parent_account_id ? text(' · 影子账号', ' · shadow account') : '' }} -
+
-
-
User-Agent
{{ accountViews[account.id]!.identity.user_agent }}
-
{{ text('身份来源', 'Identity source') }}
{{ identitySource(accountViews[account.id]!.identity.identity_source) }} · #{{ accountViews[account.id]!.identity.identity_account_id }}
-
Originator / Version
{{ accountViews[account.id]!.identity.originator }} / {{ accountViews[account.id]!.identity.version }}
-
{{ text('当前收敛状态', 'Effective convergence') }}
{{ effectiveMode(accountViews[account.id]!) }}
-
+ +
+ +

{{ settingsBusy ? text('正在保存策略,保存结束后重新读取身份', 'Saving policy; identities will reload when the save finishes') : text('正在读取完整身份…', 'Loading complete identity…') }}

+ {{ rowsLoading ? t('common.loading') : text('没有符合条件的账号', 'No matching accounts') }} @@ -91,6 +90,7 @@ import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import AppLayout from '@/components/layout/AppLayout.vue' +import CodexFingerprintIdentity from '@/components/admin/CodexFingerprintIdentity.vue' import accountsAPI from '@/api/admin/accounts' import { codexFingerprintAPI, type CodexFingerprintSettings, type CodexFingerprintSettingsView, type CodexFingerprintMode, type CodexFingerprintAccountView } from '@/api/admin/codexFingerprint' import { useAccountJobsStore, isTerminalAccountJob } from '@/stores/accountJobs' @@ -108,11 +108,18 @@ const rows = ref([]), selectedIDs = ref([]) const draftModes = reactive>({}) const rowBusy = reactive>({}) const accountViews = reactive>({}) +const storedModes = reactive>({}) +const detailLoading = reactive>({}) +const detailErrors = reactive>({}) const page = ref(1), total = ref(0), search = ref(''), activeSearch = ref(''), bulkMode = ref('device') const pageSize = 20 let generation = 0, listSerial = 0 const ownedJobID = ref(0) let listController: AbortController | null = null +let detailController = new AbortController(), detailEpoch = 0, detailSerial = 0, activeDetails = 0 +const detailVersions: Record = {} +type DetailTask = { id: number; epoch: number; serial: number; list: number; generation: number; signal: AbortSignal } +const detailQueue: DetailTask[] = [] const modes = computed(() => [ { value: 'off' as const, label: text('保留设备/会话标识', 'Preserve device/session identifiers') }, { value: 'device' as const, label: text('仅设备', 'Device only') }, @@ -124,21 +131,61 @@ const allPageSelected = computed(() => rows.value.length > 0 && rows.value.every const somePageSelected = computed(() => rows.value.some(row => selectedIDs.value.includes(row.id))) function savedMode(account: AccountListItem): CodexFingerprintMode { - const value = accountViews[account.id]?.mode || account.extra?.codex_fingerprint_mode + const value = storedModes[account.id] ?? account.extra?.codex_fingerprint_mode return modes.value.some(mode => mode.value === value) ? value as CodexFingerprintMode : 'device' } function selectAccount(id: number, selected: boolean) { selectedIDs.value = selected ? [...new Set([...selectedIDs.value, id])] : selectedIDs.value.filter(value => value !== id) } function selectPage(selected: boolean) { for (const row of rows.value) selectAccount(row.id, selected) } -function identitySource(source: string): string { - const labels: Record = { account: text('账号固定 TUI', 'Fixed account TUI'), override_ua: text('账号自定义 UA', 'Account UA override'), canonical: text('全局默认', 'Global default'), protocol_fallback: text('协议兜底;实际请求保留客户端身份', 'Protocol fallback; actual requests retain the client identity') } - return labels[source] || source +function invalidateIdentity(id: number) { + detailVersions[id] = ++detailSerial + delete accountViews[id]; delete detailErrors[id]; delete detailLoading[id] } -function effectiveMode(view: CodexFingerprintAccountView): string { - if (!view.simulation_enabled) return text('总开关已关闭', 'Master switch is off') - if (view.identity.fingerprint_reason === 'seed_missing') return text('身份种子缺失,未收敛', 'Missing identity seed; no convergence') - return modes.value.find(mode => mode.value === view.identity.fingerprint_mode_effective)?.label || view.identity.fingerprint_reason +function invalidateDetails() { + detailEpoch++; detailController.abort(); detailController = new AbortController() + detailQueue.splice(0) + for (const record of [accountViews, detailErrors, detailLoading]) for (const id of Object.keys(record)) delete record[Number(id)] +} +function taskIsCurrent(task: DetailTask): boolean { + return task.generation === generation && task.epoch === detailEpoch && task.list === listSerial && + task.serial === detailVersions[task.id] && rows.value.some(row => row.id === task.id) +} +function applyAccountView(account: AccountListItem, view: CodexFingerprintAccountView) { + const dirty = draftModes[account.id] !== savedMode(account) + storedModes[account.id] = view.mode + account.extra = { ...account.extra, codex_fingerprint_mode: view.mode } + accountViews[account.id] = view + if (!dirty) draftModes[account.id] = view.mode +} +function pumpDetails() { + while (activeDetails < 3 && detailQueue.length) { + const task = detailQueue.shift()! + if (!taskIsCurrent(task)) continue + activeDetails++ + void readIdentity(task).finally(() => { activeDetails--; pumpDetails() }) + } +} +async function readIdentity(task: DetailTask) { + try { + const view = await codexFingerprintAPI.getAccount(task.id, { signal: task.signal }) + if (!taskIsCurrent(task)) return + const account = rows.value.find(row => row.id === task.id)! + applyAccountView(account, view) + } catch (cause) { + if (taskIsCurrent(task) && !task.signal.aborted) detailErrors[task.id] = extractApiErrorMessage(cause, t('common.error')) + } finally { if (taskIsCurrent(task)) detailLoading[task.id] = false } +} +function requestIdentity(id: number) { + if (settingsBusy.value || rowsLoading.value || rowBusy[id] || detailLoading[id] || !rows.value.some(row => row.id === id)) return + delete detailErrors[id] + const serial = ++detailSerial + detailVersions[id] = serial; detailLoading[id] = true + detailQueue.push({ id, epoch: detailEpoch, serial, list: listSerial, generation, signal: detailController.signal }) + pumpDetails() +} +function loadIdentities() { + for (const row of rows.value) requestIdentity(row.id) } async function loadSettings() { const current = generation @@ -153,58 +200,52 @@ async function saveSettings() { const current = generation const sent: CodexFingerprintSettings = { enabled: draft.enabled, user_agent: draft.user_agent, client_version: draft.client_version, version_auto_sync_enabled: draft.version_auto_sync_enabled } settingsBusy.value = true; error.value = ''; message.value = '' + invalidateDetails() try { const value = await codexFingerprintAPI.saveSettings(sent) if (current !== generation) return settings.value = value; Object.assign(draft, value) - for (const id of Object.keys(accountViews)) delete accountViews[Number(id)] message.value = text('设置已保存,新请求立即生效', 'Settings saved; new requests use the new policy') } catch (cause) { if (current === generation) error.value = extractApiErrorMessage(cause, t('common.error')) } - finally { if (current === generation) settingsBusy.value = false } + finally { if (current === generation) { settingsBusy.value = false; loadIdentities() } } } async function loadRows() { const current = ++listSerial - listController?.abort(); listController = new AbortController(); rowsLoading.value = true + listController?.abort(); listController = new AbortController(); rowsLoading.value = true; invalidateDetails() + rows.value = [] + const controller = listController try { - const result = await accountsAPI.list(page.value, pageSize, { platform: 'openai', types: 'oauth,setup-token', search: activeSearch.value, lite: '0' }, { signal: listController.signal }) + const result = await accountsAPI.list(page.value, pageSize, { platform: 'openai', types: 'oauth,setup-token', search: activeSearch.value, lite: '0' }, { signal: controller.signal }) if (current !== listSerial) return rows.value = result.items; total.value = result.total - for (const row of rows.value) { delete accountViews[row.id]; draftModes[row.id] = savedMode(row) } - } catch (cause) { if (current === listSerial && !listController.signal.aborted) error.value = extractApiErrorMessage(cause, t('common.error')) } - finally { if (current === listSerial) rowsLoading.value = false } + for (const row of rows.value) { + const dirty = draftModes[row.id] !== undefined && draftModes[row.id] !== savedMode(row) + const value = row.extra?.codex_fingerprint_mode + storedModes[row.id] = modes.value.some(mode => mode.value === value) ? value as CodexFingerprintMode : 'device' + if (!dirty) draftModes[row.id] = storedModes[row.id]! + } + } catch (cause) { if (current === listSerial && !controller.signal.aborted) error.value = extractApiErrorMessage(cause, t('common.error')) } + finally { if (current === listSerial) { rowsLoading.value = false; loadIdentities() } } } function searchAccounts() { page.value = 1; activeSearch.value = search.value.trim(); void loadRows() } function changePage(delta: number) { page.value += delta; void loadRows() } -async function showIdentity(id: number) { - if (accountViews[id]) { delete accountViews[id]; return } - const current = generation - const account = rows.value.find(row => row.id === id) - const dirty = account && draftModes[id] !== savedMode(account) - rowBusy[id] = true - try { - const view = await codexFingerprintAPI.getAccount(id) - if (current === generation) { - if (account) account.extra = { ...account.extra, codex_fingerprint_mode: view.mode } - accountViews[id] = view - if (!dirty) draftModes[id] = view.mode - } - } - catch (cause) { if (current === generation) error.value = extractApiErrorMessage(cause, t('common.error')) } - finally { if (current === generation) rowBusy[id] = false } -} async function saveAccount(account: AccountListItem) { const current = generation, mode = draftModes[account.id] if (!mode || rowBusy[account.id]) return rowBusy[account.id] = true; error.value = ''; message.value = '' + invalidateIdentity(account.id) + const epoch = detailEpoch, list = listSerial try { const view = await codexFingerprintAPI.saveAccount(account.id, mode) if (current !== generation) return - accountViews[account.id] = view - account.extra = { ...account.extra, codex_fingerprint_mode: view.mode } - draftModes[account.id] = view.mode + storedModes[account.id] = view.mode + if (epoch === detailEpoch && list === listSerial) { + applyAccountView(account, view) + if (draftModes[account.id] === mode) draftModes[account.id] = view.mode + } message.value = text('账号模式已保存', 'Account mode saved') } catch (cause) { if (current === generation) error.value = extractApiErrorMessage(cause, t('common.error')) } - finally { if (current === generation) rowBusy[account.id] = false } + finally { if (current === generation) { rowBusy[account.id] = false; if (!accountViews[account.id]) requestIdentity(account.id) } } } async function saveBulk() { if (!selectedIDs.value.length || bulkBusy.value) return @@ -224,5 +265,5 @@ watch(() => jobs.visibleJobs.find(job => job.id === ownedJobID.value)?.status, ( if (job && isTerminalAccountJob(job)) { ownedJobID.value = 0; void loadRows() } }) onMounted(() => { void loadSettings(); void loadRows() }) -onBeforeUnmount(() => { generation++; listSerial++; listController?.abort() }) +onBeforeUnmount(() => { generation++; listSerial++; listController?.abort(); detailController.abort(); detailQueue.splice(0) }) diff --git a/frontend/src/views/admin/__tests__/CodexFingerprintView.spec.ts b/frontend/src/views/admin/__tests__/CodexFingerprintView.spec.ts index 02ed8b79896a..af08348622c5 100644 --- a/frontend/src/views/admin/__tests__/CodexFingerprintView.spec.ts +++ b/frontend/src/views/admin/__tests__/CodexFingerprintView.spec.ts @@ -2,6 +2,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' import { ref } from 'vue' import CodexFingerprintView from '../CodexFingerprintView.vue' +import type { CodexFingerprintAccountView, CodexFingerprintMode } from '@/api/admin/codexFingerprint' + +let language = 'zh' +let simulationEnabled = true +const serverModes: Record = {} const { getSettings, saveSettings, getAccount, saveAccount, list, bulkUpdate, track } = vi.hoisted(() => ({ getSettings: vi.fn(), saveSettings: vi.fn(), getAccount: vi.fn(), saveAccount: vi.fn(), list: vi.fn(), bulkUpdate: vi.fn(), track: vi.fn() @@ -11,11 +16,39 @@ vi.mock('@/api/admin/accounts', () => ({ default: { list, bulkUpdate } })) vi.mock('@/stores/accountJobs', () => ({ useAccountJobsStore: () => ({ visibleJobs: [], track }), isTerminalAccountJob: () => false })) vi.mock('vue-i18n', async importOriginal => ({ ...await importOriginal(), - useI18n: () => ({ t: (key: string) => key, locale: ref('zh') }) + useI18n: () => ({ t: (key: string) => key, locale: ref(language) }) })) const settings = { enabled: true, user_agent: '', client_version: '', version_auto_sync_enabled: true, synced_version: '0.161.0', effective_version: '0.161.0', effective_user_agent: 'codex_cli_rs/0.161.0 (Ubuntu 24.4.0; x86_64) xterm-256color' } -const accountView = { mode: 'full', simulation_enabled: true, identity: { user_agent: settings.effective_user_agent, originator: 'codex_cli_rs', version: '0.161.0', identity_source: 'account', identity_account_id: 1, fingerprint_mode_effective: 'full', fingerprint_reason: 'ok' } } +const accountView: CodexFingerprintAccountView = { + mode: 'full', simulation_enabled: true, + identity: { user_agent: 'codex-tui/0.161.0 (Windows 10.0.26100; x86_64) vscode/1.104.0 (codex-tui; 0.161.0)', originator: 'codex-tui', version: '0.161.0', identity_source: 'account', identity_account_id: 1, identity_persisted: true, fingerprint_mode_configured: 'full', fingerprint_mode_effective: 'full', fingerprint_reason: 'ok' }, + device_identity: { v: 1, os_type: 'Windows', os_version: '10.0.26100', arch: 'x86_64', terminal: 'vscode/1.104.0', sandbox: 'windows_sandbox', generated_at: '2026-10-08T08:00:00Z' }, + effective_device: { os_type: 'Windows', os_version: '10.0.26100', arch: 'x86_64', terminal: 'vscode/1.104.0', platform_sandbox: 'windows_sandbox' }, + identifier_policy: { + installation_id: { behavior: 'fixed', rule: 'account_device', value: 'fixed-installation' }, + session_id: { behavior: 'fixed', rule: 'account_session', value: 'fixed-session' }, + thread_id: { behavior: 'fixed', rule: 'account_thread', value: 'fixed-session' }, + parent_thread_id: { behavior: 'request_derived', rule: 'remove_parent_self_reference', value: null }, + window_id: { behavior: 'request_derived', rule: 'merged_window', value: null } + } +} +function viewFor(id: number, mode: CodexFingerprintMode = serverModes[id] || 'device'): CodexFingerprintAccountView { + const view = structuredClone(accountView) + view.mode = mode; view.simulation_enabled = simulationEnabled + view.identity.identity_account_id = id; view.identity.fingerprint_mode_configured = mode + view.identity.fingerprint_mode_effective = simulationEnabled ? mode : 'off' + if (!simulationEnabled) { + view.effective_device = null; view.identity.identity_source = 'protocol_fallback'; view.identity.fingerprint_reason = 'simulation_disabled' + view.identity.user_agent = settings.effective_user_agent + } + if (mode === 'off' || !simulationEnabled) { + for (const key of Object.keys(view.identifier_policy) as Array) view.identifier_policy[key] = { behavior: 'passthrough', rule: 'preserve_client', value: null } + } else if (mode !== 'full') { + view.identifier_policy.thread_id = { behavior: mode === 'session' ? 'request_derived' : 'passthrough', rule: mode === 'session' ? 'mapped_thread' : 'preserve_client', value: null } + } + return view +} async function open() { const wrapper = mount(CodexFingerprintView, { global: { stubs: { AppLayout: { template: '
' } } } }) await flushPromises() @@ -25,10 +58,13 @@ async function open() { describe('CodexFingerprintView', () => { beforeEach(() => { vi.clearAllMocks() + language = 'zh'; simulationEnabled = true + for (const id of Object.keys(serverModes)) delete serverModes[Number(id)] + serverModes[1] = 'full'; serverModes[2] = 'off' getSettings.mockResolvedValue({ ...settings }) - saveSettings.mockImplementation(async config => ({ ...settings, ...config })) - getAccount.mockResolvedValue({ ...accountView }) - saveAccount.mockImplementation(async (_id, mode) => ({ ...accountView, mode })) + saveSettings.mockImplementation(async config => { simulationEnabled = config.enabled; return { ...settings, ...config } }) + getAccount.mockImplementation(async id => viewFor(id)) + saveAccount.mockImplementation(async (id, mode) => { serverModes[id] = mode; return viewFor(id, mode) }) list.mockResolvedValue({ items: [{ id: 1, name: 'First', type: 'oauth', extra: { codex_fingerprint_mode: 'full' } }, { id: 2, name: 'Second', type: 'setup-token', extra: { codex_fingerprint_mode: 'off' } }], total: 2 }) bulkUpdate.mockResolvedValue({ id: 8, status: 'pending' }) }) @@ -53,11 +89,141 @@ describe('CodexFingerprintView', () => { expect(saveAccount).toHaveBeenCalledWith(1, 'session') expect(wrapper.text()).toContain(settings.effective_user_agent) expect(wrapper.get('[data-test="fingerprint-account-save-1"]').element.disabled).toBe(true) - await wrapper.findAll('button').find(button => button.text() === '查看生效身份')!.trigger('click') + await wrapper.get('[data-test="fingerprint-account-refresh-1"]').trigger('click'); await flushPromises() expect(wrapper.get('[data-test="fingerprint-account-save-1"]').element.disabled).toBe(true) wrapper.unmount() }) + it('automatically displays the complete persisted device, current policy and fixed identifiers', async () => { + const wrapper = await open() + expect(getAccount).toHaveBeenCalledWith(1, { signal: expect.any(AbortSignal) }) + const identity = wrapper.findAll('[data-test="fingerprint-identity"]')[0]! + for (const value of ['固定设备身份', '已持久保存', 'Windows', '10.0.26100', 'x86_64', 'vscode/1.104.0', 'windows_sandbox', '生成时间', '身份结构版本', '当前策略,适用于新请求', '保存的收敛模式', '生效收敛模式']) expect(identity.text()).toContain(value) + expect(identity.get('[data-test="fingerprint-installation_id"]').text()).toBe('fixed-installation') + expect(identity.text()).toContain('保留请求中的窗口序号') + expect(wrapper.text()).not.toContain('查看生效身份') + expect(wrapper.get('[data-test="fingerprint-account-refresh-1"]').attributes('aria-label')).toBe('刷新账号身份 First') + wrapper.unmount() + }) + + it('limits identity reads to three including obsolete requests that have not settled', async () => { + list.mockResolvedValue({ items: Array.from({ length: 6 }, (_, i) => ({ id: i + 1, name: 'Account ' + (i + 1), type: 'oauth', extra: {} })), total: 6 }) + const pending: Array<(view: CodexFingerprintAccountView) => void> = [] + let active = 0, maximum = 0 + getAccount.mockImplementation(() => { + active++; maximum = Math.max(maximum, active) + return new Promise(resolve => pending.push(resolve)).finally(() => { active-- }) + }) + const wrapper = await open() + expect(getAccount).toHaveBeenCalledTimes(3) + await wrapper.findAll('button').find(button => button.text() === '刷新账号')!.trigger('click'); await flushPromises() + expect(getAccount).toHaveBeenCalledTimes(3) + pending[0]!(viewFor(1)); await flushPromises() + expect(getAccount).toHaveBeenCalledTimes(4) + expect(maximum).toBe(3) + wrapper.unmount() + for (const finish of pending) finish(viewFor(1)) + await flushPromises() + }) + + it('isolates a failed identity read and retries the row without overwriting a draft edited during the read', async () => { + let finish!: (view: CodexFingerprintAccountView) => void + getAccount.mockImplementationOnce(() => new Promise(resolve => { finish = resolve })) + const wrapper = await open() + await wrapper.get('[data-test="fingerprint-mode-1"]').setValue('session') + finish(viewFor(1)); await flushPromises() + expect(wrapper.get('[data-test="fingerprint-mode-1"]').element.value).toBe('session') + getAccount.mockRejectedValueOnce(new Error('identity read failed')) + await wrapper.get('[data-test="fingerprint-account-refresh-1"]').trigger('click'); await flushPromises() + expect(wrapper.get('[role="alert"]').text()).toContain('identity read failed') + expect(wrapper.findAll('[data-test="fingerprint-identity"]')).toHaveLength(2) + await wrapper.get('[data-test="fingerprint-account-retry-1"]').trigger('click'); await flushPromises() + expect(wrapper.find('[role="alert"]').exists()).toBe(false) + expect(wrapper.get('[data-test="fingerprint-mode-1"]').element.value).toBe('session') + await wrapper.findAll('button').find(button => button.text() === '刷新账号')!.trigger('click'); await flushPromises() + expect(wrapper.get('[data-test="fingerprint-mode-1"]').element.value).toBe('session') + wrapper.unmount() + }) + + it('rejects a late identity response after changing pages', async () => { + let finish!: (view: CodexFingerprintAccountView) => void + list.mockImplementation(async page => ({ items: [{ id: page, name: page === 1 ? 'First' : 'Second', type: 'oauth', extra: {} }], total: 40 })) + getAccount.mockImplementationOnce(() => new Promise(resolve => { finish = resolve })) + const wrapper = await open() + const signal = getAccount.mock.calls[0]![1].signal as AbortSignal + await wrapper.findAll('button').find(button => button.text() === '下一页')!.trigger('click'); await flushPromises() + expect(signal.aborted).toBe(true) + const obsolete = viewFor(1); obsolete.identity.user_agent = 'obsolete-page-identity' + finish(obsolete); await flushPromises() + expect(wrapper.text()).toContain('Second') + expect(wrapper.text()).not.toContain('obsolete-page-identity') + expect(wrapper.findAll('[data-test="fingerprint-identity"]')).toHaveLength(1) + wrapper.unmount() + }) + + it('reloads identities after a global save and rejects the old policy even if cancellation is ignored', async () => { + let finish!: (view: CodexFingerprintAccountView) => void + getAccount.mockImplementationOnce(() => new Promise(resolve => { finish = resolve })) + const wrapper = await open() + await wrapper.get('[data-test="fingerprint-enabled"]').setValue(false) + await wrapper.find('form').trigger('submit.prevent'); await flushPromises() + expect(wrapper.findAll('[data-test="fingerprint-client-identity"]')).toHaveLength(2) + const obsolete = structuredClone(accountView); obsolete.identity.user_agent = 'obsolete-policy-identity' + finish(obsolete); await flushPromises() + expect(wrapper.text()).not.toContain('obsolete-policy-identity') + expect(wrapper.findAll('[data-test="fingerprint-client-identity"]')).toHaveLength(2) + expect(wrapper.text()).toContain('已持久保存') + wrapper.unmount() + }) + + it('does not let an identity read overwrite a subsequently saved mode', async () => { + let finish!: (view: CodexFingerprintAccountView) => void + getAccount.mockImplementationOnce(() => new Promise(resolve => { finish = resolve })) + const wrapper = await open() + await wrapper.get('[data-test="fingerprint-mode-1"]').setValue('session') + await wrapper.get('[data-test="fingerprint-account-save-1"]').trigger('click'); await flushPromises() + const obsolete = structuredClone(accountView); obsolete.identity.user_agent = 'obsolete-mode-identity' + finish(obsolete); await flushPromises() + expect(wrapper.get('[data-test="fingerprint-mode-1"]').element.value).toBe('session') + expect(wrapper.get('[data-test="fingerprint-account-save-1"]').element.disabled).toBe(true) + expect(wrapper.text()).not.toContain('obsolete-mode-identity') + wrapper.unmount() + }) + + it('shows an explicitly missing identity instead of blank fields that imply a saved device', async () => { + getAccount.mockImplementation(async id => { + const view = viewFor(id) + view.device_identity = null; view.identity.identity_persisted = false + view.identity.fingerprint_reason = 'seed_missing'; view.identity.fingerprint_mode_effective = 'off' + return view + }) + const wrapper = await open() + expect(wrapper.findAll('[data-test="fingerprint-persistence"]')[0]!.text()).toBe('身份缺失') + expect(wrapper.text()).toContain('身份种子缺失,未收敛') + wrapper.unmount() + }) + + it('shows English override, parent source, unknown UA fields and an unpersisted identity without inventing a time', async () => { + language = 'en' + getAccount.mockImplementation(async id => { + const view = viewFor(id) + view.identity.identity_source = 'override_ua' + if (id === 1) { + view.identity.identity_account_id = 9 + view.effective_device = { os_type: 'Mac OS X', os_version: '14.0', arch: 'arm64', terminal: 'iTerm.app/3.5.15', platform_sandbox: 'seatbelt' } + } else { + view.effective_device = { os_type: null, os_version: null, arch: null, terminal: null, platform_sandbox: null } + view.identity.identity_persisted = false + delete view.device_identity!.generated_at + } + return view + }) + const wrapper = await open() + for (const value of ['Fixed device identity', 'account UA override replaces', 'parent account identity', 'Mac OS X', 'Windows', 'Cannot determine from UA', 'Derived from the seed', 'Not recorded']) expect(wrapper.text()).toContain(value) + expect(wrapper.get('[data-test="fingerprint-account-refresh-1"]').attributes('aria-label')).toBe('Refresh account identity First') + wrapper.unmount() + }) + it('freezes selected IDs and submits an explicit off mode as the only bulk update', async () => { let finish!: (job: unknown) => void bulkUpdate.mockImplementationOnce(() => new Promise(resolve => { finish = resolve })) @@ -80,6 +246,8 @@ describe('CodexFingerprintView', () => { await wrapper.find('form').trigger('submit.prevent'); await flushPromises() expect(wrapper.get('[role="alert"]').text()).toContain('save failed') expect(wrapper.get('[data-test="fingerprint-enabled"]').element.checked).toBe(false) + expect(wrapper.find('[data-test="fingerprint-client-identity"]').exists()).toBe(false) + expect(wrapper.findAll('[data-test="fingerprint-identity"]')).toHaveLength(2) wrapper.unmount() getSettings.mockRejectedValueOnce(new Error('load failed')) const refused = await open()