diff --git a/.changes/global-dingtalk-region.md b/.changes/global-dingtalk-region.md new file mode 100644 index 00000000..b488979d --- /dev/null +++ b/.changes/global-dingtalk-region.md @@ -0,0 +1,5 @@ +--- +category: Added +--- + +- **International DingTalk region support** — adds `.io` login and MCP routing, pre-release endpoint overrides, and profile-aware gateway selection while preserving the existing `.com` flow. diff --git a/README.md b/README.md index b29410ae..ec3e7416 100644 --- a/README.md +++ b/README.md @@ -786,6 +786,7 @@ See [`docs/robot-quickstart.md`](./docs/robot-quickstart.md) for the full 4-step ## Reference & Docs +- [International DingTalk (`.io`) guide](./docs/international-region-guide.md) — international login, domestic/international profile switching, isolated testing, and troubleshooting - [Command Index](./docs/command-index.md) — every runtime command with description and when-to-use guidance - [Reference](./docs/reference.md) — environment variables, exit codes, output formats, shell completion - [Architecture](./docs/architecture.md) — static endpoint pipeline, command surface, transport layer diff --git a/README_zh.md b/README_zh.md index b61cb62d..e47544a4 100644 --- a/README_zh.md +++ b/README_zh.md @@ -777,6 +777,7 @@ dws dev connect --channel auto --robot-client-id --robot-client-secret \ + --client-secret +``` + +This mode still requires the user to complete OAuth authorization in a browser; it is not a userless `client_credentials` login. The application must be configured on the international developer platform with the required callback and permissions. Never commit an AppSecret to source control or include it in logs. + +## Verify the login + +```bash +dws auth status --format json +dws profile list --format json +dws contact user get-self +``` + +The last command is a read-only smoke check. If the organization has not enabled CLI access, an organization administrator must enable it or approve the access request on the international developer platform. + +## Use domestic and international profiles together + +```bash +# Domestic (.com) +dws auth login + +# International (.io) +dws auth login --intl + +# Find the stable profile selectors +dws profile list --format json +``` + +Persistently switch profiles: + +```bash +dws profile switch : +``` + +Toggle back to the previous profile: + +```bash +dws profile switch - +``` + +Select a profile for one command without changing the default: + +```bash +dws --profile : contact user get-self +``` + +Do not add `--intl` to business commands. DWS routes official endpoints from the selected profile's Token region. + +## Isolated smoke testing + +Use a separate configuration directory to avoid changing the normal `~/.dws` login state: + +```bash +DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws auth login --intl +DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws auth status --format json +DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws contact user get-self +``` + +Use the same `DWS_CONFIG_DIR` for every command. Use `./dws` for a source build and `dws` for an installed release. + +## Pre-release overrides (maintainers only) + +Normal international users need only `--intl`; they should not set `--pre-url` or `--mcp-url`. + +Maintainers can test the pre-release login/MCP pair with: + +```bash +dws auth login --intl --pre-url https://pre-login.dingtalk.io +``` + +A corresponding `pre-mcp.*` URL is also accepted, and DWS derives the paired `pre-login.*` / `pre-mcp.*` bases. `--mcp-url` explicitly overrides the MCP base URL for that login. + +Pre-release services may require internal network access or allowlisted accounts. `--pre-url` is intended primarily for the MCP-managed credential flow. Do not combine it with direct custom `--client-id/--client-secret` mode unless the pre-release API contract explicitly supports that combination. + +## Troubleshooting + +### The browser still opens a `.com` page + +1. Run `dws auth login --help` and confirm `--intl` is present. +2. For a source checkout, use `./dws` instead of an older installed binary. +3. Confirm the executed command is `dws auth login --intl`. + +### A business command appears to use the wrong region + +Run `dws profile list --format json`, then switch with the exact `:` selector or use the global `--profile` option. For a legacy Token created before region metadata existed, reauthorize it with `dws auth login --intl` for an international account or `dws auth login` for a domestic account. + +### Login succeeds but the command reports missing permission + +This normally means the organization has not enabled CLI access or the application lacks a required permission. It does not by itself indicate a region-routing failure. + +### Should I edit `~/.dws/mcp_url` manually? + +No. Normal users should establish the login with `dws auth login` or `dws auth login --intl`. DWS then routes official endpoints from the selected Token/profile. Manual configuration is reserved for maintainers who explicitly control the target environment. + +## Command reference + +| Scenario | Command | +|---|---| +| Domestic browser login | `dws auth login` | +| International browser login | `dws auth login --intl` | +| International device login | `dws auth login --intl --device` | +| Check auth state | `dws auth status --format json` | +| List profiles | `dws profile list --format json` | +| Persistently switch profile | `dws profile switch :` | +| Toggle to previous profile | `dws profile switch -` | +| Select a profile once | `dws --profile : ` | diff --git a/docs/international-region-guide.zh-CN.md b/docs/international-region-guide.zh-CN.md new file mode 100644 index 00000000..8ad9d3ac --- /dev/null +++ b/docs/international-region-guide.zh-CN.md @@ -0,0 +1,185 @@ +# DWS 国际版(DingTalk `.io`)使用手册 + +本手册适用于使用钉钉国际版账号登录并调用国际站服务的用户。 + +## 核心规则 + +- `dws auth login --intl` 创建或刷新国际版登录,使用 `*.dingtalk.io` 登录、鉴权和 MCP 服务。 +- 不传 `--intl` 时仍使用国内钉钉 `*.dingtalk.com`,原有链路保持不变。 +- `--intl` 只用于登录命令。登录完成后,`contact`、`calendar`、`doc` 等业务命令不需要再传该参数。 +- 每个 Token 会记录登录区域。执行业务命令时,DWS 根据当前或 `--profile` 指定的账号自动选择 `.com` 或 `.io` 网关。 +- `--international` 是 `--intl` 的兼容别名;新脚本推荐使用较短的 `--intl`。 + +## 确认当前版本支持国际版 + +运行: + +```bash +dws auth login --help +``` + +帮助中应包含: + +```text +--intl +--international +``` + +从源码分支验证时,先在仓库根目录构建,并始终使用本次构建的 `./dws`,避免误用系统中已安装的旧版本: + +```bash +make build +./dws auth login --help +``` + +## 国际版登录 + +### 浏览器登录 + +```bash +dws auth login --intl +``` + +DWS 会打开国际版登录页面。完成扫码或账号授权后,登录结果会保存为本机 profile。 + +### 设备码登录 + +适用于 SSH、容器或没有可用浏览器的环境: + +```bash +dws auth login --intl --device +``` + +按照终端提示,在另一台可打开浏览器的设备上完成授权。 + +### 使用自有应用凭证完成用户 OAuth + +```bash +dws auth login --intl \ + --client-id \ + --client-secret +``` + +该模式仍然需要用户在浏览器中完成 OAuth 授权,不是无用户授权的 `client_credentials` 登录。应用必须在国际版开放平台正确配置回调地址和所需权限。不要在命令历史、日志或 PR 中提交真实的 AppSecret。 + +## 验证登录和业务调用 + +查看当前登录状态: + +```bash +dws auth status --format json +``` + +列出本机全部账号并找到当前 profile: + +```bash +dws profile list --format json +``` + +执行一个只读命令验证国际链路,例如: + +```bash +dws contact user get-self +``` + +登录状态正常但业务命令提示组织未开通 CLI 时,需要由国际版组织管理员在国际版开发者平台开启 CLI 访问或完成授权审批。 + +## 国内版和国际版账号并存 + +可以在同一台机器上分别登录国内版和国际版账号: + +```bash +# 国内版(.com) +dws auth login + +# 国际版(.io) +dws auth login --intl + +# 查看稳定的 profile 选择器 +dws profile list --format json +``` + +持久切换账号: + +```bash +dws profile switch : +``` + +切回上一个账号: + +```bash +dws profile switch - +``` + +只为单次命令指定账号,不修改默认账号: + +```bash +dws --profile : contact user get-self +``` + +DWS 会按照选中 profile 的 Token 区域自动选择 `.com` 或 `.io`,不需要在业务命令上追加 `--intl`。 + +## 使用独立配置目录进行验证 + +如果不希望测试登录影响日常使用的 `~/.dws`,可以指定独立配置目录: + +```bash +DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws auth login --intl +DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws auth status --format json +DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws contact user get-self +``` + +请在三条命令中使用同一个 `DWS_CONFIG_DIR`。验证源码分支时使用 `./dws`;验证已安装版本时可改为 `dws`。 + +## 预发参数(仅维护者) + +普通国际版用户只需要 `--intl`,不要配置 `--pre-url` 或 `--mcp-url`。 + +维护者验证预发登录/MCP 链路时可以使用: + +```bash +dws auth login --intl --pre-url https://pre-login.dingtalk.io +``` + +也可以传入对应的 `pre-mcp.*` 地址;DWS 会推导配套的 `pre-login.*` / `pre-mcp.*` 地址。`--mcp-url` 用于显式覆盖本次登录的 MCP base URL。 + +预发环境可能只对内网或特定测试账号开放。`--pre-url` 主要服务于 MCP 托管凭证登录流程;除非预发 API 契约已经明确支持,否则不要把它与自有 `--client-id/--client-secret` 直连模式组合使用。 + +## 常见问题 + +### 仍然打开 `.com` 登录页面 + +1. 运行 `dws auth login --help`,确认当前二进制包含 `--intl`。 +2. 从源码验证时使用 `./dws`,不要误用 PATH 中的旧版本。 +3. 确认实际执行的是 `dws auth login --intl`,而不是普通 `dws auth login`。 + +### 业务命令似乎使用了错误区域 + +先检查当前账号: + +```bash +dws profile list --format json +``` + +然后使用精确的 `:` 切换或通过全局 `--profile` 单次指定。对于在区域字段引入前生成的历史 Token,建议使用正确的登录方式重新授权:国际账号执行 `dws auth login --intl`,国内账号执行 `dws auth login`。 + +### 登录成功但提示没有权限 + +这通常是组织 CLI 准入或应用授权问题,不代表区域路由失败。请确认目标组织已开启 CLI 访问,并且当前应用拥有命令所需权限。 + +### 是否需要手工修改 `~/.dws/mcp_url` + +不需要。正常使用应通过 `dws auth login` 或 `dws auth login --intl` 建立登录态;业务命令会根据选中的 Token/profile 自动路由。手工修改配置只适用于明确了解目标环境的维护者调试场景。 + +## 命令速查 + +| 场景 | 命令 | +|---|---| +| 国内版浏览器登录 | `dws auth login` | +| 国际版浏览器登录 | `dws auth login --intl` | +| 国际版设备码登录 | `dws auth login --intl --device` | +| 查看登录状态 | `dws auth status --format json` | +| 查看所有账号 | `dws profile list --format json` | +| 持久切换账号 | `dws profile switch :` | +| 切回上一个账号 | `dws profile switch -` | +| 单次指定账号 | `dws --profile : ` | diff --git a/internal/app/access_token_manager_test.go b/internal/app/access_token_manager_test.go index e996b7be..358015d2 100644 --- a/internal/app/access_token_manager_test.go +++ b/internal/app/access_token_manager_test.go @@ -65,12 +65,16 @@ func TestCrossPlatformCoverageTokenManagerCachesUntilMarkerRevisionChanges(t *te token := "token-a" installTokenManagerFakes(t, func() (*authpkg.TokenData, error) { calls.Add(1) - return &authpkg.TokenData{AccessToken: token, ExpiresAt: time.Now().Add(time.Hour)}, nil + return &authpkg.TokenData{ + AccessToken: token, + ExpiresAt: time.Now().Add(time.Hour), + LoginRegion: string(authpkg.LoginRegionInternational), + }, nil }) manager := NewTokenManager() first, err := manager.Get(context.Background(), configDir, "") - if err != nil || first.AccessToken != "token-a" { + if err != nil || first.AccessToken != "token-a" || first.LoginRegion != authpkg.LoginRegionInternational || !first.LoginRegionKnown { t.Fatalf("first token = %#v, %v", first, err) } second, err := manager.Get(context.Background(), configDir, "") diff --git a/internal/app/access_token_resolve.go b/internal/app/access_token_resolve.go index c5c969d9..df605673 100644 --- a/internal/app/access_token_resolve.go +++ b/internal/app/access_token_resolve.go @@ -41,9 +41,11 @@ type accessTokenSnapshotGetter interface { // AccessTokenSnapshot is the minimal bearer view needed by the process cache. // Refresh-token material never leaves the auth package. type AccessTokenSnapshot struct { - AccessToken string - ExpiresAt time.Time - Source string + AccessToken string + ExpiresAt time.Time + Source string + LoginRegion authpkg.LoginRegion + LoginRegionKnown bool } type tokenManagerKey struct { @@ -223,9 +225,11 @@ func resolveAccessTokenSnapshotFromDir(ctx context.Context, configDir, profile s data, err := snapshotProvider.GetTokenSnapshot(ctx) if err == nil && data != nil && strings.TrimSpace(data.AccessToken) != "" { return AccessTokenSnapshot{ - AccessToken: strings.TrimSpace(data.AccessToken), - ExpiresAt: data.ExpiresAt, - Source: "oauth", + AccessToken: strings.TrimSpace(data.AccessToken), + ExpiresAt: data.ExpiresAt, + Source: "oauth", + LoginRegion: authpkg.LoginRegion(strings.TrimSpace(data.LoginRegion)), + LoginRegionKnown: true, }, nil } if err != nil && !errors.Is(err, authpkg.ErrTokenDataNotFound) { diff --git a/internal/app/agent_metadata_test.go b/internal/app/agent_metadata_test.go index 2bae8254..3caaa926 100644 --- a/internal/app/agent_metadata_test.go +++ b/internal/app/agent_metadata_test.go @@ -712,9 +712,9 @@ func TestCrossPlatformCoverageAgentMetadataMCPAndPluginScoping(t *testing.T) { // calls must fail before preflight or transport while anonymous plugins remain // valid above. resolveCalled := false - testseam.Swap(t, &runnerResolveAuthToken, func(*runtimeRunner, context.Context) (string, error) { + testseam.Swap(t, &runnerResolveAuthSnapshot, func(*runtimeRunner, context.Context) (AccessTokenSnapshot, error) { resolveCalled = true - return "", nil + return AccessTokenSnapshot{}, nil }) callsBefore := len(captured) unauthenticated := &runtimeRunner{ diff --git a/internal/app/auth_command.go b/internal/app/auth_command.go index e887341b..c914d755 100644 --- a/internal/app/auth_command.go +++ b/internal/app/auth_command.go @@ -20,6 +20,8 @@ import ( "encoding/json" "fmt" "io" + "net" + "net/url" "os" "path/filepath" "runtime" @@ -48,8 +50,24 @@ type authLoginConfig struct { TargetCorpID string HistoryProfileSelector string HistoryProfileSelectorExplicit bool + International bool + PreURL string + MCPURL string } +type authLoginEndpointOverrides struct { + LoginURL string + MCPURL string +} + +type authLoginMCPPersistence uint8 + +const ( + authLoginMCPUseDefault authLoginMCPPersistence = iota + authLoginMCPUseManagedRegion + authLoginMCPUseExplicitOverride +) + type authLoginGuideAction string const ( @@ -103,12 +121,17 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { 支持的登录方式: - OAuth Loopback 流 (默认): 本机自动起 127.0.0.1 监听接收回调,浏览器授权后自动完成 - OAuth 设备流 (--device): 显示 user_code + 短 URL,适合 SSH 远程 / 容器 / 无头环境 + - 自有应用 OAuth (--client-id/--client-secret): 使用指定应用完成用户授权 - 直接提供 Token (--token): 跳过授权,使用已有 token 不支持的登录方式: - 邮箱/密码登录 - 手机号/验证码登录 - - 应用凭证 (AppKey/AppSecret) 直接登录 + - 无用户授权的纯应用凭证 (client_credentials) 登录 + +区域: + - 默认使用国内钉钉 .com 登录与服务端点 + - --intl(或 --international)使用国际版 .io 登录;后续业务命令按所选 profile 自动路由 注意: SSH 远程或无头环境(无本地浏览器可访问远端的 127.0.0.1)请使用 --device, 否则 OAuth 回调会跳到本机不可达的 127.0.0.1 链接,授权完成后无法回写 token。 @@ -116,6 +139,9 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { 示例: dws auth login # 本机登录并新增/刷新一个组织 profile dws auth login --profile # 指定本次授权目标组织,不持久切换当前组织 + dws auth login --intl # 使用钉钉国际版 .io 登录入口 + dws auth login --intl --pre-url https://pre-login.dingtalk.io + dws auth login --intl --pre-url https://pre-mcp.dingtalk.io dws auth login --recommend # 无交互批量授权服务端推荐权限 dws auth login --device # SSH 远程 / 无头环境登录 (设备流) dws auth login --force # 兼容保留;login 默认已忽略缓存并进入授权流程 @@ -126,6 +152,22 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { if err != nil { return err } + var preOverrides authLoginEndpointOverrides + if cfg.PreURL != "" { + var err error + preOverrides, err = authLoginEndpointOverridesForPreURL(cfg.PreURL) + if err != nil { + return err + } + restoreLoginBaseURL := authpkg.PushLoginBaseURLOverride(preOverrides.LoginURL) + defer restoreLoginBaseURL() + } + mcpBaseURL, mcpPersistence, err := authLoginMCPBaseURLForConfig(cfg, preOverrides) + if err != nil { + return err + } + restoreMCPBaseURL := authpkg.PushMCPBaseURLOverride(mcpBaseURL) + defer restoreMCPBaseURL() configDir := defaultConfigDir() var tokenData *authpkg.TokenData format, _ := cmd.Root().PersistentFlags().GetString("format") @@ -139,6 +181,9 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { AccessToken: cfg.Token, ExpiresAt: time.Now().Add(config.ManualTokenExpiry), } + if cfg.International { + tokenData.LoginRegion = string(authpkg.LoginRegionInternational) + } if err := authSaveTokenData(configDir, tokenData); err != nil { return apperrors.NewInternal(fmt.Sprintf("failed to persist auth token: %v", err)) } @@ -149,6 +194,9 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { provider := authpkg.NewDeviceFlowProvider(configDir, nil) provider.Output = cmd.ErrOrStderr() provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser") + if cfg.International { + provider.SetLoginRegion(authpkg.LoginRegionInternational) + } provider.IdentityEnricher = func(ctx context.Context, data *authpkg.TokenData) error { return enrichAuthLoginProfileFromContact(ctx, configDir, patCaller, data, authLoginHistoryHint{ Selector: cfg.HistoryProfileSelector, @@ -167,6 +215,9 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { provider.Output = cmd.ErrOrStderr() provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser") provider.TargetCorpID = cfg.TargetCorpID + if cfg.International { + provider.LoginRegion = authpkg.LoginRegionInternational + } provider.IdentityEnricher = func(ctx context.Context, data *authpkg.TokenData) error { return enrichAuthLoginProfileFromContact(ctx, configDir, patCaller, data, authLoginHistoryHint{ Selector: cfg.HistoryProfileSelector, @@ -180,6 +231,11 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { } } + if tokenData != nil { + if err := persistAuthLoginMCPBaseURL(configDir, mcpBaseURL, mcpPersistence); err != nil { + return apperrors.NewInternal(fmt.Sprintf("failed to persist MCP URL: %v", err)) + } + } ResetRuntimeTokenCache() clearCompatCache() w := cmd.OutOrStdout() @@ -278,6 +334,10 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { } cmd.Flags().String("token", "", "Access token") cmd.Flags().Bool("device", false, "Use device authorization flow") + cmd.Flags().Bool("intl", false, "Use DingTalk international (.io) login and service endpoints") + cmd.Flags().Bool("international", false, "Use DingTalk international (.io) login and service endpoints") + cmd.Flags().String("pre-url", "", "Override pre-release login/MCP base URL for this login") + cmd.Flags().String("mcp-url", "", "Override MCP base URL for this login") cmd.Flags().Bool("force", false, "兼容保留;login 默认已忽略缓存并进入授权流程") cmd.Flags().Bool("recommend", false, "登录成功后无交互批量授权服务端推荐权限") // Hidden compatibility flags @@ -967,6 +1027,7 @@ func newAuthResetCommand() *cobra.Command { return apperrors.NewInternal(fmt.Sprintf("failed to reset token data: %v", err)) } _ = authRemove(filepath.Join(configDir, "mcp_url")) + _ = authRemove(filepath.Join(configDir, config.ManagedMCPURLRegionFileName)) _ = authRemove(filepath.Join(configDir, "token")) _ = authDeleteAppConfig(configDir) ResetRuntimeTokenCache() @@ -1225,6 +1286,14 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) { if err != nil { return authLoginConfig{}, apperrors.NewInternal("failed to read --device") } + intl, err := cmd.Flags().GetBool("intl") + if err != nil { + return authLoginConfig{}, apperrors.NewInternal("failed to read --intl") + } + international, err := cmd.Flags().GetBool("international") + if err != nil { + return authLoginConfig{}, apperrors.NewInternal("failed to read --international") + } force, err := cmd.Flags().GetBool("force") if err != nil { return authLoginConfig{}, apperrors.NewInternal("failed to read --force") @@ -1233,6 +1302,14 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) { if err != nil { return authLoginConfig{}, apperrors.NewInternal("failed to read --recommend") } + preURL, err := cmd.Flags().GetString("pre-url") + if err != nil { + return authLoginConfig{}, apperrors.NewInternal("failed to read --pre-url") + } + mcpURL, err := cmd.Flags().GetString("mcp-url") + if err != nil { + return authLoginConfig{}, apperrors.NewInternal("failed to read --mcp-url") + } yes := false profileSelector := "" if cmd.Root() != nil { @@ -1266,9 +1343,172 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) { TargetCorpID: targetCorpID, HistoryProfileSelector: historyProfileSelector, HistoryProfileSelectorExplicit: historyProfileSelectorExplicit, + International: intl || international, + PreURL: strings.TrimSpace(preURL), + MCPURL: strings.TrimSpace(mcpURL), }, nil } +func authLoginEndpointOverridesForPreURL(raw string) (authLoginEndpointOverrides, error) { + parsed, normalized, err := normalizeAuthLoginBaseURL(raw, "--pre-url") + if err != nil { + return authLoginEndpointOverrides{}, err + } + host := strings.ToLower(parsed.Hostname()) + switch { + case strings.HasPrefix(host, "pre-login."): + return authLoginEndpointOverrides{ + LoginURL: normalized, + MCPURL: authLoginURLWithHost(parsed, "pre-mcp."+strings.TrimPrefix(host, "pre-login.")), + }, nil + case strings.HasPrefix(host, "pre-mcp."): + return authLoginEndpointOverrides{ + LoginURL: authLoginURLWithHost(parsed, "pre-login."+strings.TrimPrefix(host, "pre-mcp.")), + MCPURL: normalized, + }, nil + default: + return authLoginEndpointOverrides{}, apperrors.NewValidation("--pre-url must be a pre-login.* or pre-mcp.* URL") + } +} + +func authLoginMCPBaseURLForConfig(cfg authLoginConfig, preOverrides authLoginEndpointOverrides) (string, authLoginMCPPersistence, error) { + if cfg.MCPURL != "" { + _, normalized, err := normalizeAuthLoginBaseURL(cfg.MCPURL, "--mcp-url") + if err != nil { + return "", authLoginMCPUseDefault, err + } + return normalized, authLoginMCPUseExplicitOverride, nil + } + if cfg.PreURL != "" { + if preOverrides.MCPURL == "" { + var err error + preOverrides, err = authLoginEndpointOverridesForPreURL(cfg.PreURL) + if err != nil { + return "", authLoginMCPUseDefault, err + } + } + return preOverrides.MCPURL, authLoginMCPUseExplicitOverride, nil + } + if cfg.International { + return authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion, nil + } + return authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault, nil +} + +func persistAuthLoginMCPBaseURL(configDir, mcpBaseURL string, persistence authLoginMCPPersistence) error { + mcpURLPath := filepath.Join(configDir, "mcp_url") + managedRegionPath := filepath.Join(configDir, config.ManagedMCPURLRegionFileName) + + switch persistence { + case authLoginMCPUseExplicitOverride: + if err := removeAuthLoginManagedMCPRegion(managedRegionPath); err != nil { + return fmt.Errorf("clear managed MCP region: %w", err) + } + if err := authAtomicWrite(mcpURLPath, []byte(mcpBaseURL), config.FilePerm); err != nil { + return fmt.Errorf("save explicit MCP URL: %w", err) + } + return nil + case authLoginMCPUseManagedRegion: + managedURL, managedErr := authReadFile(managedRegionPath) + if managedErr != nil && !os.IsNotExist(managedErr) { + return fmt.Errorf("read managed MCP region: %w", managedErr) + } + currentURL, currentErr := authReadFile(mcpURLPath) + switch { + case currentErr == nil && os.IsNotExist(managedErr): + return nil + case currentErr == nil && strings.TrimSpace(string(currentURL)) != strings.TrimSpace(string(managedURL)): + return removeAuthLoginManagedMCPRegion(managedRegionPath) + case currentErr != nil && !os.IsNotExist(currentErr): + return fmt.Errorf("read MCP URL: %w", currentErr) + } + if err := authAtomicWrite(managedRegionPath, []byte(mcpBaseURL), config.FilePerm); err != nil { + return fmt.Errorf("save managed MCP region: %w", err) + } + if err := authAtomicWrite(mcpURLPath, []byte(mcpBaseURL), config.FilePerm); err != nil { + _ = authRemove(managedRegionPath) + return fmt.Errorf("save managed MCP URL: %w", err) + } + return nil + case authLoginMCPUseDefault: + managedURL, err := authReadFile(managedRegionPath) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return fmt.Errorf("read managed MCP region: %w", err) + } + currentURL, err := authReadFile(mcpURLPath) + if os.IsNotExist(err) { + return removeAuthLoginManagedMCPRegion(managedRegionPath) + } + if err != nil { + return fmt.Errorf("read MCP URL: %w", err) + } + if strings.TrimSpace(string(currentURL)) != strings.TrimSpace(string(managedURL)) { + return removeAuthLoginManagedMCPRegion(managedRegionPath) + } + if err := authAtomicWrite(mcpURLPath, []byte(mcpBaseURL), config.FilePerm); err != nil { + return fmt.Errorf("restore default MCP URL: %w", err) + } + return removeAuthLoginManagedMCPRegion(managedRegionPath) + default: + return fmt.Errorf("unsupported MCP persistence mode %d", persistence) + } +} + +func removeAuthLoginManagedMCPRegion(path string) error { + if err := authRemove(path); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +func normalizeAuthLoginBaseURL(raw, flagName string) (*url.URL, string, error) { + value := strings.TrimSpace(raw) + if value == "" { + return nil, "", apperrors.NewValidation(flagName + " cannot be empty") + } + if !strings.Contains(value, "://") { + value = "https://" + value + } + parsed, err := url.Parse(value) + if err != nil { + return nil, "", apperrors.NewValidation(fmt.Sprintf("invalid %s: %v", flagName, err)) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return nil, "", apperrors.NewValidation(flagName + " must use http or https") + } + if parsed.Hostname() == "" { + return nil, "", apperrors.NewValidation(flagName + " must include a host") + } + if parsed.Scheme == "http" && !isAuthLoginLoopbackHost(parsed.Hostname()) { + return nil, "", apperrors.NewValidation(flagName + " must use HTTPS, except for a loopback HTTP test endpoint") + } + parsed.RawQuery = "" + parsed.Fragment = "" + parsed.Path = strings.TrimRight(parsed.Path, "/") + return parsed, strings.TrimRight(parsed.String(), "/"), nil +} + +func isAuthLoginLoopbackHost(host string) bool { + if strings.EqualFold(strings.TrimSpace(host), "localhost") { + return true + } + ip := net.ParseIP(strings.TrimSpace(host)) + return ip != nil && ip.IsLoopback() +} + +func authLoginURLWithHost(parsed *url.URL, host string) string { + copyURL := *parsed + if port := parsed.Port(); port != "" { + copyURL.Host = net.JoinHostPort(host, port) + } else { + copyURL.Host = host + } + return strings.TrimRight(copyURL.String(), "/") +} + func authLoginForcesAuthorization(_ authLoginConfig) bool { return true } diff --git a/internal/app/auth_command_full_coverage_test.go b/internal/app/auth_command_full_coverage_test.go index b572a375..2afdd00f 100644 --- a/internal/app/auth_command_full_coverage_test.go +++ b/internal/app/auth_command_full_coverage_test.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "os" + "path/filepath" "strings" "testing" "time" @@ -214,7 +215,8 @@ func TestCrossPlatformCoverageAuthCoverageFormsParentAndTargets(t *testing.T) { } func TestCrossPlatformCoverageAuthCoverageLoginFlows(t *testing.T) { - t.Setenv("DWS_CONFIG_DIR", t.TempDir()) + configDir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", configDir) oldSave := authSaveTokenData oldDevice := authDeviceLogin oldOAuth := authOAuthLogin @@ -255,6 +257,15 @@ func TestCrossPlatformCoverageAuthCoverageLoginFlows(t *testing.T) { if out, _, err := authCoverageRunLogin(t, nil, "json", true, map[string]string{"token": "token"}); err != nil || !strings.Contains(out, `"token_valid": true`) { t.Fatalf("json token login = %q, %v", out, err) } + if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"token": "token", "pre-url": "https://example.com"}); err == nil { + t.Fatal("invalid pre-release host should fail") + } + if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"token": "token", "mcp-url": "http://remote.example.com"}); err == nil { + t.Fatal("remote plaintext MCP URL should fail") + } + if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"token": "token", "pre-url": "https://pre-login.dingtalk.io"}); err != nil { + t.Fatalf("pre-release token login = %v", err) + } authDeviceLogin = func(*authpkg.DeviceFlowProvider, context.Context) (*authpkg.TokenData, error) { return nil, errors.New("device") @@ -271,6 +282,15 @@ func TestCrossPlatformCoverageAuthCoverageLoginFlows(t *testing.T) { if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"device": "true", "no-browser": "true"}); err != nil { t.Fatal(err) } + authDeviceLogin = func(provider *authpkg.DeviceFlowProvider, _ context.Context) (*authpkg.TokenData, error) { + if provider.LoginRegion != authpkg.LoginRegionInternational { + t.Errorf("device login region = %q, want international", provider.LoginRegion) + } + return &authpkg.TokenData{AccessToken: "a", ExpiresAt: time.Now().Add(time.Hour)}, nil + } + if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"device": "true", "intl": "true"}); err != nil { + t.Fatalf("international device login = %v", err) + } authOAuthLogin = func(*authpkg.OAuthProvider, context.Context, bool) (*authpkg.TokenData, error) { return nil, errors.New("oauth") @@ -291,6 +311,25 @@ func TestCrossPlatformCoverageAuthCoverageLoginFlows(t *testing.T) { if out, _, err := authCoverageRunLogin(t, caller, "table", true, map[string]string{"no-browser": "true"}); err != nil || !strings.Contains(out, "Corp") { t.Fatalf("oauth success = %q, %v", out, err) } + authOAuthLogin = func(provider *authpkg.OAuthProvider, _ context.Context, _ bool) (*authpkg.TokenData, error) { + if provider.LoginRegion != authpkg.LoginRegionInternational { + t.Errorf("OAuth login region = %q, want international", provider.LoginRegion) + } + return &authpkg.TokenData{AccessToken: "a", ExpiresAt: time.Now().Add(time.Hour)}, nil + } + if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"intl": "true"}); err != nil { + t.Fatalf("international OAuth login = %v", err) + } + + blockedConfigDir := t.TempDir() + if err := os.Mkdir(filepath.Join(blockedConfigDir, "mcp_url"), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("DWS_CONFIG_DIR", blockedConfigDir) + if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"token": "token", "intl": "true"}); err == nil || !strings.Contains(err.Error(), "failed to persist MCP URL") { + t.Fatalf("MCP URL persist failure = %v", err) + } + t.Setenv("DWS_CONFIG_DIR", configDir) authRunLoginRecommend = func(context.Context, edition.ToolCaller, io.Writer, pat.LoginRecommendOptions) error { return errors.New("recommend") @@ -1418,7 +1457,7 @@ func TestCrossPlatformCoverageAuthCoveragePortableExchangeAndReset(t *testing.T) authRemove = func(string) error { removed++; return errors.New("ignored") } authDeleteAppConfig = func(string) error { removed++; return errors.New("ignored") } edition.Override(&edition.Hooks{}) - if err := reset.RunE(reset, nil); err != nil || removed != 3 || !strings.Contains(out.String(), "重新登录") { + if err := reset.RunE(reset, nil); err != nil || removed != 4 || !strings.Contains(out.String(), "重新登录") { t.Fatalf("reset = %q, %v, removed=%d", out.String(), err, removed) } edition.Override(&edition.Hooks{IsEmbedded: true}) diff --git a/internal/app/auth_command_test.go b/internal/app/auth_command_test.go index 486cd98a..31371e4a 100644 --- a/internal/app/auth_command_test.go +++ b/internal/app/auth_command_test.go @@ -33,6 +33,8 @@ import ( apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/spf13/cobra" ) @@ -1033,8 +1035,12 @@ func TestResolveAuthLoginConfigReadsInheritedYes(t *testing.T) { login := &cobra.Command{Use: "login"} login.Flags().String("token", "", "") login.Flags().Bool("device", false, "") + login.Flags().Bool("intl", false, "") + login.Flags().Bool("international", false, "") login.Flags().Bool("force", false, "") login.Flags().Bool("recommend", false, "") + login.Flags().String("pre-url", "", "") + login.Flags().String("mcp-url", "", "") root.AddCommand(login) if err := root.PersistentFlags().Set("yes", "true"); err != nil { @@ -1061,6 +1067,560 @@ func TestResolveAuthLoginConfigReadsInheritedYes(t *testing.T) { } } +func TestCrossPlatformCoverageResolveAuthLoginConfigReadsInternationalAliases(t *testing.T) { + for _, flag := range []string{"intl", "international"} { + t.Run(flag, func(t *testing.T) { + root := &cobra.Command{Use: "dws"} + root.PersistentFlags().Bool("yes", false, "") + login := &cobra.Command{Use: "login"} + login.Flags().String("token", "", "") + login.Flags().Bool("device", false, "") + login.Flags().Bool("intl", false, "") + login.Flags().Bool("international", false, "") + login.Flags().Bool("force", false, "") + login.Flags().Bool("recommend", false, "") + login.Flags().String("pre-url", "", "") + login.Flags().String("mcp-url", "", "") + root.AddCommand(login) + + if err := login.Flags().Set(flag, "true"); err != nil { + t.Fatalf("set %s: %v", flag, err) + } + + cfg, err := resolveAuthLoginConfig(login) + if err != nil { + t.Fatalf("resolveAuthLoginConfig error = %v", err) + } + if !cfg.International { + t.Fatalf("International = false for --%s, want true", flag) + } + }) + } + + for _, tc := range []struct { + name string + setup func(*cobra.Command) + }{ + { + name: "missing intl", + setup: func(cmd *cobra.Command) { + cmd.Flags().String("token", "", "") + cmd.Flags().Bool("device", false, "") + }, + }, + { + name: "missing international", + setup: func(cmd *cobra.Command) { + cmd.Flags().String("token", "", "") + cmd.Flags().Bool("device", false, "") + cmd.Flags().Bool("intl", false, "") + }, + }, + { + name: "missing pre url", + setup: func(cmd *cobra.Command) { + cmd.Flags().String("token", "", "") + cmd.Flags().Bool("device", false, "") + cmd.Flags().Bool("intl", false, "") + cmd.Flags().Bool("international", false, "") + cmd.Flags().Bool("force", false, "") + cmd.Flags().Bool("recommend", false, "") + }, + }, + { + name: "missing mcp url", + setup: func(cmd *cobra.Command) { + cmd.Flags().String("token", "", "") + cmd.Flags().Bool("device", false, "") + cmd.Flags().Bool("intl", false, "") + cmd.Flags().Bool("international", false, "") + cmd.Flags().Bool("force", false, "") + cmd.Flags().Bool("recommend", false, "") + cmd.Flags().String("pre-url", "", "") + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + cmd := &cobra.Command{Use: "login"} + tc.setup(cmd) + if _, err := resolveAuthLoginConfig(cmd); err == nil { + t.Fatal("resolveAuthLoginConfig succeeded with an incomplete flag set") + } + }) + } +} + +func TestCrossPlatformCoverageResolveAuthLoginConfigReadsMCPURL(t *testing.T) { + root := &cobra.Command{Use: "dws"} + root.PersistentFlags().Bool("yes", false, "") + login := &cobra.Command{Use: "login"} + login.Flags().String("token", "", "") + login.Flags().Bool("device", false, "") + login.Flags().Bool("intl", false, "") + login.Flags().Bool("international", false, "") + login.Flags().Bool("force", false, "") + login.Flags().Bool("recommend", false, "") + login.Flags().String("pre-url", "", "") + login.Flags().String("mcp-url", "", "") + root.AddCommand(login) + + if err := login.Flags().Set("mcp-url", " https://pre-mcp.dingtalk.io/ "); err != nil { + t.Fatalf("set mcp-url: %v", err) + } + + cfg, err := resolveAuthLoginConfig(login) + if err != nil { + t.Fatalf("resolveAuthLoginConfig error = %v", err) + } + if cfg.MCPURL != "https://pre-mcp.dingtalk.io/" { + t.Fatalf("MCPURL = %q, want trimmed flag value", cfg.MCPURL) + } +} + +func TestCrossPlatformCoverageResolveAuthLoginConfigReadsPreURL(t *testing.T) { + root := &cobra.Command{Use: "dws"} + root.PersistentFlags().Bool("yes", false, "") + login := &cobra.Command{Use: "login"} + login.Flags().String("token", "", "") + login.Flags().Bool("device", false, "") + login.Flags().Bool("intl", false, "") + login.Flags().Bool("international", false, "") + login.Flags().Bool("force", false, "") + login.Flags().Bool("recommend", false, "") + login.Flags().String("pre-url", "", "") + login.Flags().String("mcp-url", "", "") + root.AddCommand(login) + + if err := login.Flags().Set("pre-url", " pre-login.dingtalk.io "); err != nil { + t.Fatalf("set pre-url: %v", err) + } + + cfg, err := resolveAuthLoginConfig(login) + if err != nil { + t.Fatalf("resolveAuthLoginConfig error = %v", err) + } + if cfg.PreURL != "pre-login.dingtalk.io" { + t.Fatalf("PreURL = %q, want trimmed flag value", cfg.PreURL) + } +} + +func TestCrossPlatformCoverageAuthLoginEndpointOverridesForPreURL(t *testing.T) { + tests := []struct { + name string + raw string + wantLogin string + wantMCP string + }{ + { + name: "pre login", + raw: "https://pre-login.dingtalk.io/", + wantLogin: "https://pre-login.dingtalk.io", + wantMCP: "https://pre-mcp.dingtalk.io", + }, + { + name: "pre mcp", + raw: "pre-mcp.dingtalk.io", + wantLogin: "https://pre-login.dingtalk.io", + wantMCP: "https://pre-mcp.dingtalk.io", + }, + { + name: "pre login with port", + raw: "https://pre-login.dingtalk.io:8443/path/", + wantLogin: "https://pre-login.dingtalk.io:8443/path", + wantMCP: "https://pre-mcp.dingtalk.io:8443/path", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := authLoginEndpointOverridesForPreURL(tc.raw) + if err != nil { + t.Fatalf("authLoginEndpointOverridesForPreURL error = %v", err) + } + if got.LoginURL != tc.wantLogin || got.MCPURL != tc.wantMCP { + t.Fatalf("overrides = %#v, want login %q mcp %q", got, tc.wantLogin, tc.wantMCP) + } + }) + } + + for _, raw := range []string{"https://example.com", "http://pre-login.example.com"} { + if _, err := authLoginEndpointOverridesForPreURL(raw); err == nil { + t.Fatalf("authLoginEndpointOverridesForPreURL(%q) succeeded", raw) + } + } +} + +func TestCrossPlatformCoverageAuthLoginMCPBaseURLForConfig(t *testing.T) { + tests := []struct { + name string + cfg authLoginConfig + preOverride authLoginEndpointOverrides + wantURL string + wantPersistence authLoginMCPPersistence + }{ + { + name: "default center login uses com and resets only managed region", + cfg: authLoginConfig{}, + wantURL: authpkg.DefaultMCPBaseURL, + wantPersistence: authLoginMCPUseDefault, + }, + { + name: "international login persists managed io", + cfg: authLoginConfig{International: true}, + wantURL: authpkg.InternationalMCPBaseURL, + wantPersistence: authLoginMCPUseManagedRegion, + }, + { + name: "pre login persists mapped pre mcp", + cfg: authLoginConfig{PreURL: "pre-login.dingtalk.io"}, + preOverride: authLoginEndpointOverrides{ + LoginURL: "https://pre-login.dingtalk.io", + MCPURL: "https://pre-mcp.dingtalk.io", + }, + wantURL: "https://pre-mcp.dingtalk.io", + wantPersistence: authLoginMCPUseExplicitOverride, + }, + { + name: "explicit mcp url wins over pre url", + cfg: authLoginConfig{ + PreURL: "pre-login.dingtalk.io", + MCPURL: " https://custom-mcp.example.com/ ", + }, + preOverride: authLoginEndpointOverrides{ + LoginURL: "https://pre-login.dingtalk.io", + MCPURL: "https://pre-mcp.dingtalk.io", + }, + wantURL: "https://custom-mcp.example.com", + wantPersistence: authLoginMCPUseExplicitOverride, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + gotURL, gotPersistence, err := authLoginMCPBaseURLForConfig(tc.cfg, tc.preOverride) + if err != nil { + t.Fatalf("authLoginMCPBaseURLForConfig error = %v", err) + } + if gotURL != tc.wantURL || gotPersistence != tc.wantPersistence { + t.Fatalf("got url=%q persistence=%v, want url=%q persistence=%v", gotURL, gotPersistence, tc.wantURL, tc.wantPersistence) + } + }) + } + + for _, cfg := range []authLoginConfig{ + {MCPURL: "http://remote.example.com"}, + {PreURL: "https://example.com"}, + } { + if _, _, err := authLoginMCPBaseURLForConfig(cfg, authLoginEndpointOverrides{}); err == nil { + t.Fatalf("authLoginMCPBaseURLForConfig(%#v) succeeded", cfg) + } + } +} + +func TestCrossPlatformCoveragePersistAuthLoginMCPBaseURL(t *testing.T) { + t.Run("persists selected io mcp url", func(t *testing.T) { + configDir := t.TempDir() + if err := persistAuthLoginMCPBaseURL(configDir, authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); err != nil { + t.Fatalf("persistAuthLoginMCPBaseURL error = %v", err) + } + data, err := os.ReadFile(filepath.Join(configDir, "mcp_url")) + if err != nil { + t.Fatalf("ReadFile(mcp_url) error = %v", err) + } + if string(data) != authpkg.InternationalMCPBaseURL { + t.Fatalf("mcp_url = %q, want %q", string(data), authpkg.InternationalMCPBaseURL) + } + managed, err := os.ReadFile(filepath.Join(configDir, config.ManagedMCPURLRegionFileName)) + if err != nil || string(managed) != authpkg.InternationalMCPBaseURL { + t.Fatalf("managed MCP region = %q, %v", string(managed), err) + } + }) + + t.Run("center login preserves previous persisted override", func(t *testing.T) { + configDir := t.TempDir() + mcpURLPath := filepath.Join(configDir, "mcp_url") + const customURL = "https://custom-mcp.example.com" + if err := os.WriteFile(mcpURLPath, []byte(customURL), 0o600); err != nil { + t.Fatalf("WriteFile(mcp_url) error = %v", err) + } + if err := persistAuthLoginMCPBaseURL(configDir, authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); err != nil { + t.Fatalf("persistAuthLoginMCPBaseURL error = %v", err) + } + data, err := os.ReadFile(mcpURLPath) + if err != nil { + t.Fatalf("ReadFile(mcp_url) error = %v", err) + } + if string(data) != customURL { + t.Fatalf("mcp_url = %q, want preserved override %q", string(data), customURL) + } + }) + + t.Run("international login preserves an unmanaged explicit override", func(t *testing.T) { + configDir := t.TempDir() + mcpURLPath := filepath.Join(configDir, "mcp_url") + const customURL = "https://private-mcp.example.com" + if err := os.WriteFile(mcpURLPath, []byte(customURL), config.FilePerm); err != nil { + t.Fatal(err) + } + if err := persistAuthLoginMCPBaseURL(configDir, authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(mcpURLPath) + if err != nil || string(data) != customURL { + t.Fatalf("explicit mcp_url = %q, %v; want preserved custom URL", string(data), err) + } + if _, err := os.Stat(filepath.Join(configDir, config.ManagedMCPURLRegionFileName)); !os.IsNotExist(err) { + t.Fatalf("unmanaged override acquired a managed marker: %v", err) + } + }) + + t.Run("center login resets a managed international URL", func(t *testing.T) { + configDir := t.TempDir() + if err := persistAuthLoginMCPBaseURL(configDir, authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); err != nil { + t.Fatal(err) + } + if err := persistAuthLoginMCPBaseURL(configDir, authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(filepath.Join(configDir, "mcp_url")) + if err != nil || string(data) != authpkg.DefaultMCPBaseURL { + t.Fatalf("mcp_url = %q, %v; want domestic default", string(data), err) + } + if _, err := os.Stat(filepath.Join(configDir, config.ManagedMCPURLRegionFileName)); !os.IsNotExist(err) { + t.Fatalf("managed region marker remains after domestic login: %v", err) + } + }) + + t.Run("explicit override clears region ownership", func(t *testing.T) { + configDir := t.TempDir() + if err := persistAuthLoginMCPBaseURL(configDir, authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); err != nil { + t.Fatal(err) + } + const customURL = "https://custom-mcp.example.com" + if err := persistAuthLoginMCPBaseURL(configDir, customURL, authLoginMCPUseExplicitOverride); err != nil { + t.Fatal(err) + } + if err := persistAuthLoginMCPBaseURL(configDir, authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(filepath.Join(configDir, "mcp_url")) + if err != nil || string(data) != customURL { + t.Fatalf("explicit mcp_url = %q, %v; want preserved custom URL", string(data), err) + } + }) + + t.Run("stale marker never deletes a different custom URL", func(t *testing.T) { + configDir := t.TempDir() + if err := os.WriteFile(filepath.Join(configDir, "mcp_url"), []byte("https://custom.example.com"), config.FilePerm); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(configDir, config.ManagedMCPURLRegionFileName), []byte(authpkg.InternationalMCPBaseURL), config.FilePerm); err != nil { + t.Fatal(err) + } + if err := persistAuthLoginMCPBaseURL(configDir, authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(filepath.Join(configDir, "mcp_url")) + if err != nil || string(data) != "https://custom.example.com" { + t.Fatalf("custom mcp_url = %q, %v", string(data), err) + } + }) + + t.Run("international login clears a stale marker without changing a custom URL", func(t *testing.T) { + configDir := t.TempDir() + mcpURLPath := filepath.Join(configDir, "mcp_url") + managedRegionPath := filepath.Join(configDir, config.ManagedMCPURLRegionFileName) + const customURL = "https://private-mcp.example.com" + if err := os.WriteFile(mcpURLPath, []byte(customURL), config.FilePerm); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(managedRegionPath, []byte(authpkg.DefaultMCPBaseURL), config.FilePerm); err != nil { + t.Fatal(err) + } + if err := persistAuthLoginMCPBaseURL(configDir, authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(mcpURLPath) + if err != nil || string(data) != customURL { + t.Fatalf("custom mcp_url = %q, %v", string(data), err) + } + if _, err := os.Stat(managedRegionPath); !os.IsNotExist(err) { + t.Fatalf("stale managed marker remains: %v", err) + } + }) +} + +func TestCrossPlatformCoveragePersistAuthLoginMCPBaseURLErrors(t *testing.T) { + fail := errors.New("persist failure") + + t.Run("explicit marker cleanup", func(t *testing.T) { + testseam.Swap(t, &authRemove, func(string) error { return fail }) + if err := persistAuthLoginMCPBaseURL(t.TempDir(), "https://custom.example.com", authLoginMCPUseExplicitOverride); !errors.Is(err, fail) { + t.Fatalf("explicit marker cleanup error = %v", err) + } + }) + + t.Run("explicit URL write", func(t *testing.T) { + testseam.Swap(t, &authAtomicWrite, func(string, []byte, os.FileMode) error { return fail }) + if err := persistAuthLoginMCPBaseURL(t.TempDir(), "https://custom.example.com", authLoginMCPUseExplicitOverride); !errors.Is(err, fail) { + t.Fatalf("explicit URL write error = %v", err) + } + }) + + t.Run("managed marker write", func(t *testing.T) { + testseam.Swap(t, &authAtomicWrite, func(string, []byte, os.FileMode) error { return fail }) + if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); !errors.Is(err, fail) { + t.Fatalf("managed marker write error = %v", err) + } + }) + + t.Run("managed marker read", func(t *testing.T) { + testseam.Swap(t, &authReadFile, func(string) ([]byte, error) { return nil, fail }) + if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); !errors.Is(err, fail) { + t.Fatalf("managed marker read error = %v", err) + } + }) + + t.Run("managed MCP URL read", func(t *testing.T) { + reads := 0 + testseam.Swap(t, &authReadFile, func(string) ([]byte, error) { + reads++ + if reads == 1 { + return nil, os.ErrNotExist + } + return nil, fail + }) + if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); !errors.Is(err, fail) { + t.Fatalf("managed MCP URL read error = %v", err) + } + }) + + t.Run("managed stale marker cleanup", func(t *testing.T) { + reads := 0 + testseam.Swap(t, &authReadFile, func(string) ([]byte, error) { + reads++ + if reads == 1 { + return []byte(authpkg.DefaultMCPBaseURL), nil + } + return []byte("https://private-mcp.example.com"), nil + }) + testseam.Swap(t, &authRemove, func(string) error { return fail }) + if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); !errors.Is(err, fail) { + t.Fatalf("managed stale marker cleanup error = %v", err) + } + }) + + t.Run("managed URL write cleans marker", func(t *testing.T) { + writes := 0 + removed := false + testseam.Swap(t, &authAtomicWrite, func(string, []byte, os.FileMode) error { + writes++ + if writes == 2 { + return fail + } + return nil + }) + testseam.Swap(t, &authRemove, func(string) error { removed = true; return nil }) + if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); !errors.Is(err, fail) || !removed { + t.Fatalf("managed URL write error = %v, marker removed=%v", err, removed) + } + }) + + t.Run("default managed marker read", func(t *testing.T) { + testseam.Swap(t, &authReadFile, func(string) ([]byte, error) { return nil, fail }) + if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); !errors.Is(err, fail) { + t.Fatalf("managed marker read error = %v", err) + } + }) + + t.Run("missing MCP URL cleans marker", func(t *testing.T) { + reads := 0 + testseam.Swap(t, &authReadFile, func(string) ([]byte, error) { + reads++ + if reads == 1 { + return []byte(authpkg.InternationalMCPBaseURL), nil + } + return nil, os.ErrNotExist + }) + testseam.Swap(t, &authRemove, func(string) error { return nil }) + if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); err != nil { + t.Fatalf("missing MCP URL cleanup error = %v", err) + } + }) + + t.Run("MCP URL read", func(t *testing.T) { + reads := 0 + testseam.Swap(t, &authReadFile, func(string) ([]byte, error) { + reads++ + if reads == 1 { + return []byte(authpkg.InternationalMCPBaseURL), nil + } + return nil, fail + }) + if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); !errors.Is(err, fail) { + t.Fatalf("MCP URL read error = %v", err) + } + }) + + t.Run("default URL write", func(t *testing.T) { + testseam.Swap(t, &authReadFile, func(string) ([]byte, error) { return []byte(authpkg.InternationalMCPBaseURL), nil }) + testseam.Swap(t, &authAtomicWrite, func(string, []byte, os.FileMode) error { return fail }) + if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); !errors.Is(err, fail) { + t.Fatalf("default URL write error = %v", err) + } + }) + + t.Run("final marker cleanup", func(t *testing.T) { + testseam.Swap(t, &authReadFile, func(string) ([]byte, error) { return []byte(authpkg.InternationalMCPBaseURL), nil }) + testseam.Swap(t, &authAtomicWrite, func(string, []byte, os.FileMode) error { return nil }) + testseam.Swap(t, &authRemove, func(string) error { return fail }) + if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); !errors.Is(err, fail) { + t.Fatalf("final marker cleanup error = %v", err) + } + }) + + if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPPersistence(255)); err == nil { + t.Fatal("unsupported MCP persistence mode succeeded") + } +} + +func TestCrossPlatformCoverageNormalizeAuthLoginBaseURLTransportSecurity(t *testing.T) { + tests := []struct { + name string + raw string + wantURL string + wantErr string + }{ + {name: "https remote", raw: "https://pre-mcp.example.com/path/?secret=drop#fragment", wantURL: "https://pre-mcp.example.com/path"}, + {name: "http localhost", raw: "http://localhost:8080/", wantURL: "http://localhost:8080"}, + {name: "http IPv4 loopback", raw: "http://127.0.0.1:8080", wantURL: "http://127.0.0.1:8080"}, + {name: "http IPv6 loopback", raw: "http://[::1]:8080", wantURL: "http://[::1]:8080"}, + {name: "http remote", raw: "http://pre-mcp.example.com", wantErr: "must use HTTPS"}, + {name: "empty", raw: " ", wantErr: "cannot be empty"}, + {name: "invalid URL", raw: "https://%", wantErr: "invalid --mcp-url"}, + {name: "invalid scheme", raw: "ftp://pre-mcp.example.com", wantErr: "must use http or https"}, + {name: "missing host", raw: "https:///path", wantErr: "must include a host"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, got, err := normalizeAuthLoginBaseURL(tc.raw, "--mcp-url") + if tc.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("normalizeAuthLoginBaseURL error = %v, want containing %q", err, tc.wantErr) + } + return + } + if err != nil { + t.Fatalf("normalizeAuthLoginBaseURL error = %v", err) + } + if got != tc.wantURL { + t.Fatalf("normalized URL = %q, want %q", got, tc.wantURL) + } + }) + } +} + func TestAuthLoginForcesAuthorizationByDefault(t *testing.T) { if !authLoginForcesAuthorization(authLoginConfig{}) { t.Fatal("auth login should force authorization by default so each login can add an organization profile") diff --git a/internal/app/coverage_edges_test.go b/internal/app/coverage_edges_test.go index db7e8f86..6f93ea55 100644 --- a/internal/app/coverage_edges_test.go +++ b/internal/app/coverage_edges_test.go @@ -36,6 +36,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport" upgradepkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes" tea "github.com/charmbracelet/bubbletea" @@ -326,6 +327,29 @@ func TestCrossPlatformCoverageSmallAppRegistryAndRootCoverage(t *testing.T) { func TestCrossPlatformCoverageDirectRuntimeCoverage(t *testing.T) { oldEdition := edition.Get() t.Cleanup(func() { edition.Override(oldEdition); SetDynamicServers(nil) }) + for _, tc := range []struct { + raw string + region authpkg.LoginRegion + want string + }{ + {raw: "%", want: "%"}, + {raw: "https://dingtalk.io/path", want: "https://dingtalk.com/path"}, + {raw: "https://mcp.dingtalk.com:8443/path", region: authpkg.LoginRegionInternational, want: "https://mcp.dingtalk.io:8443/path"}, + } { + if got := mcpBaseURLForLoginRegion(tc.raw, tc.region); got != tc.want { + t.Fatalf("mcpBaseURLForLoginRegion(%q, %q) = %q, want %q", tc.raw, tc.region, got, tc.want) + } + } + if hasDirectRuntimeEndpointOverride("") { + t.Fatal("blank product unexpectedly has an endpoint override") + } + t.Setenv("DINGTALK_COVERAGE_PRODUCT_MCP_URL", "https://override.test") + if !hasDirectRuntimeEndpointOverride("coverage-product") { + t.Fatal("configured product endpoint override was not detected") + } + if got := activeDingTalkGatewayEndpointWithBase("https://mcp-gw.dingtalk.com/server/contact", "%"); got != "https://mcp-gw.dingtalk.com/server/contact" { + t.Fatalf("invalid gateway base rewrote endpoint to %q", got) + } server := mcptypes.ServerDescriptor{ Endpoint: "https://one.test", CLI: mcptypes.CLIOverlay{ @@ -913,12 +937,21 @@ func TestCrossPlatformCoverageAuthCommandPureCoverage(t *testing.T) { } func TestCrossPlatformCoverageAuthLoginTokenCommandCoverage(t *testing.T) { + t.Setenv(keychain.DisableKeychainEnv, "1") + t.Setenv(keychain.StorageDirEnv, t.TempDir()) oldInteractive := authLoginInteractiveTerminal authLoginInteractiveTerminal = func() bool { return false } t.Cleanup(func() { authLoginInteractiveTerminal = oldInteractive; authpkg.SetRuntimeProfile("") }) - for _, format := range []string{"table", "json"} { - t.Run(format, func(t *testing.T) { - t.Setenv("DWS_CONFIG_DIR", t.TempDir()) + for _, tc := range []struct { + format string + international bool + }{ + {format: "table"}, + {format: "json", international: true}, + } { + t.Run(tc.format, func(t *testing.T) { + configDir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", configDir) root := &cobra.Command{Use: "dws"} root.PersistentFlags().String("format", "table", "") root.PersistentFlags().Bool("yes", false, "") @@ -929,16 +962,90 @@ func TestCrossPlatformCoverageAuthLoginTokenCommandCoverage(t *testing.T) { root.SetOut(&output) root.SetErr(io.Discard) args := []string{"login", "--token", "manual-token", "--yes"} - if format == "json" { + if tc.international { + args = append(args, "--intl") + } + if tc.format == "json" { args = append(args, "--format", "json") } root.SetArgs(args) if err := root.Execute(); err != nil || output.Len() == 0 { t.Fatalf("token login = %q %v", output.String(), err) } + data, err := authpkg.LoadTokenData(configDir) + if err != nil { + t.Fatalf("LoadTokenData error = %v", err) + } + wantRegion := "" + if tc.international { + wantRegion = string(authpkg.LoginRegionInternational) + } + if data.LoginRegion != wantRegion { + t.Fatalf("LoginRegion = %q, want %q", data.LoginRegion, wantRegion) + } + if tc.international { + snapshot, err := resolveAccessTokenSnapshotFromDir(context.Background(), configDir, "") + if err != nil { + t.Fatalf("resolveAccessTokenSnapshotFromDir error = %v", err) + } + gotEndpoint := activeDingTalkGatewayEndpointForLoginRegion( + "https://mcp-gw.dingtalk.com/server/contact", + snapshot.LoginRegion, + ) + if wantEndpoint := "https://mcp-gw.dingtalk.io/server/contact"; gotEndpoint != wantEndpoint { + t.Fatalf("international manual-token endpoint = %q, want %q", gotEndpoint, wantEndpoint) + } + } }) } + t.Run("international then domestic login restores domestic MCP URL", func(t *testing.T) { + configDir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", configDir) + runLogin := func(international bool) { + t.Helper() + root := &cobra.Command{Use: "dws"} + root.PersistentFlags().String("format", "table", "") + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().String("profile", "", "") + root.AddCommand(newAuthLoginCommand(nil)) + args := []string{"login", "--token", "manual-token", "--yes"} + if international { + args = append(args, "--intl") + } + root.SetArgs(args) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + if err := root.Execute(); err != nil { + t.Fatalf("international=%v login error = %v", international, err) + } + } + + runLogin(true) + if data, err := os.ReadFile(filepath.Join(configDir, "mcp_url")); err != nil || string(data) != authpkg.InternationalMCPBaseURL { + t.Fatalf("international mcp_url = %q, %v", string(data), err) + } + runLogin(false) + if data, err := os.ReadFile(filepath.Join(configDir, "mcp_url")); err != nil || string(data) != authpkg.DefaultMCPBaseURL { + t.Fatalf("domestic mcp_url = %q, %v", string(data), err) + } + if _, err := os.Stat(filepath.Join(configDir, config.ManagedMCPURLRegionFileName)); !os.IsNotExist(err) { + t.Fatalf("managed MCP region marker remains: %v", err) + } + + const customURL = "https://private-mcp.example.com" + if err := os.WriteFile(filepath.Join(configDir, "mcp_url"), []byte(customURL), config.FilePerm); err != nil { + t.Fatal(err) + } + runLogin(true) + if data, err := os.ReadFile(filepath.Join(configDir, "mcp_url")); err != nil || string(data) != customURL { + t.Fatalf("explicit mcp_url after international login = %q, %v; want preserved custom URL", string(data), err) + } + if _, err := os.Stat(filepath.Join(configDir, config.ManagedMCPURLRegionFileName)); !os.IsNotExist(err) { + t.Fatalf("explicit mcp_url acquired a managed marker: %v", err) + } + }) + for _, hidden := range []bool{false, true} { old := edition.Get() edition.Override(&edition.Hooks{HideAuthLogin: hidden}) diff --git a/internal/app/direct_runtime.go b/internal/app/direct_runtime.go index 1597d6bd..6bec3b11 100644 --- a/internal/app/direct_runtime.go +++ b/internal/app/direct_runtime.go @@ -74,6 +74,20 @@ func defaultPATMCPEndpoint() string { func defaultPATGatewayBaseURL() string { raw := strings.TrimSpace(authpkg.GetMCPBaseURL()) + return mcpGatewayBaseURL(raw) +} + +func defaultPATGatewayBaseURLForLoginRegion(region authpkg.LoginRegion) string { + raw := strings.TrimSpace(authpkg.GetMCPBaseURL()) + if override := authpkg.MCPBaseURLOverride(); override != "" { + raw = override + } else { + raw = mcpBaseURLForLoginRegion(raw, region) + } + return mcpGatewayBaseURL(raw) +} + +func mcpGatewayBaseURL(raw string) string { parsed, err := url.Parse(raw) if err != nil || parsed.Scheme == "" || parsed.Host == "" { return strings.TrimRight(raw, "/") @@ -100,6 +114,33 @@ func defaultPATGatewayBaseURL() string { return strings.TrimRight(parsed.String(), "/") } +func mcpBaseURLForLoginRegion(raw string, region authpkg.LoginRegion) string { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return raw + } + host := strings.ToLower(parsed.Hostname()) + fromSuffix, toSuffix := ".dingtalk.io", ".dingtalk.com" + if region.IsInternational() { + fromSuffix, toSuffix = toSuffix, fromSuffix + } + bareFrom := strings.TrimPrefix(fromSuffix, ".") + if host != bareFrom && !strings.HasSuffix(host, fromSuffix) { + return raw + } + if host == bareFrom { + host = strings.TrimPrefix(toSuffix, ".") + } else { + host = strings.TrimSuffix(host, fromSuffix) + toSuffix + } + if port := parsed.Port(); port != "" { + parsed.Host = net.JoinHostPort(host, port) + } else { + parsed.Host = host + } + return parsed.String() +} + // SetDynamicServers injects server data discovered from servers.json. // All product endpoints are resolved dynamically from this data. func SetDynamicServers(servers []mcptypes.ServerDescriptor) { @@ -131,7 +172,7 @@ func registerDynamicServer(server mcptypes.ServerDescriptor, endpoints map[strin return } id := strings.TrimSpace(server.CLI.ID) - endpoint := strings.TrimSpace(server.Endpoint) + endpoint := activeDingTalkGatewayEndpoint(server.Endpoint) if id != "" && endpoint != "" { endpoints[id] = endpoint products[id] = true @@ -262,6 +303,19 @@ func directRuntimeEndpoint(productID, toolName string) (string, bool) { return "", false } +func hasDirectRuntimeEndpointOverride(productID string) bool { + normalized := normalizeDirectRuntimeProductID(productID) + for _, candidate := range []string{strings.TrimSpace(productID), normalized} { + if candidate == "" { + continue + } + if _, ok := productEndpointOverride(candidate); ok { + return true + } + } + return false +} + func editionServerEndpoint(productID string) (string, bool) { productID = strings.TrimSpace(productID) if productID == "" { @@ -282,7 +336,7 @@ func endpointFromEditionServers(productID string, fn func() []edition.ServerInfo return "", false } for _, server := range fn() { - endpoint := strings.TrimSpace(server.Endpoint) + endpoint := activeDingTalkGatewayEndpoint(server.Endpoint) if endpoint == "" { continue } @@ -298,6 +352,46 @@ func endpointFromEditionServers(productID string, fn func() []edition.ServerInfo return "", false } +func activeDingTalkGatewayEndpoint(endpoint string) string { + return activeDingTalkGatewayEndpointWithBase(endpoint, defaultPATGatewayBaseURL()) +} + +func activeDingTalkGatewayEndpointForLoginRegion(endpoint string, region authpkg.LoginRegion) string { + return activeDingTalkGatewayEndpointWithBase(endpoint, defaultPATGatewayBaseURLForLoginRegion(region)) +} + +func activeDingTalkGatewayEndpointWithBase(endpoint, gatewayBaseURL string) string { + endpoint = strings.TrimSpace(endpoint) + parsed, err := url.Parse(endpoint) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return endpoint + } + if !isDingTalkMCPGatewayHost(parsed.Hostname()) { + return endpoint + } + base, err := url.Parse(gatewayBaseURL) + if err != nil || base.Scheme == "" || base.Host == "" { + return endpoint + } + parsed.Scheme = base.Scheme + parsed.Host = base.Host + return strings.TrimRight(parsed.String(), "/") +} + +func isDingTalkMCPGatewayHost(host string) bool { + switch strings.ToLower(strings.TrimSpace(host)) { + case "mcp-gw.dingtalk.com", "pre-mcp-gw.dingtalk.com", "mcp-gw.dingtalk.io", "pre-mcp-gw.dingtalk.io": + return true + default: + return false + } +} + +func isDingTalkMCPGatewayEndpoint(endpoint string) bool { + parsed, err := url.Parse(strings.TrimSpace(endpoint)) + return err == nil && isDingTalkMCPGatewayHost(parsed.Hostname()) +} + // DirectRuntimeProductIDs returns product IDs that should stay visible for // direct runtime execution. Dynamic products come from MCP discovery/plugin // registration; built-in helper products such as devapp resolve their endpoint diff --git a/internal/app/root_help_test.go b/internal/app/root_help_test.go index 2224ad22..20ba3635 100644 --- a/internal/app/root_help_test.go +++ b/internal/app/root_help_test.go @@ -22,6 +22,7 @@ import ( "strings" "testing" + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" @@ -488,6 +489,80 @@ func TestInjectStaticServersMergesStaticAndSupplementServers(t *testing.T) { } } +func TestCrossPlatformCoverageStaticDingTalkEndpointsFollowConfiguredMCPBaseURL(t *testing.T) { + previous := edition.Get() + defer edition.Override(previous) + defer SetDynamicServers(nil) + + configDir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", configDir) + if err := os.WriteFile(filepath.Join(configDir, "mcp_url"), []byte("https://pre-mcp.dingtalk.io\n"), 0o600); err != nil { + t.Fatalf("WriteFile(mcp_url) error = %v", err) + } + + edition.Override(&edition.Hooks{ + Name: "test", + StaticServers: func() []edition.ServerInfo { + return []edition.ServerInfo{{ + ID: "contact", + Name: "Contact", + Endpoint: "https://mcp-gw.dingtalk.com/server/contact?key=abc", + Prefixes: []string{"user"}, + }} + }, + }) + + injectStaticServers() + + for _, productID := range []string{"contact", "user"} { + got, ok := directRuntimeEndpoint(productID, "") + want := "https://pre-mcp-gw.dingtalk.io/server/contact?key=abc" + if !ok || got != want { + t.Fatalf("directRuntimeEndpoint(%q) = %q, %v; want %q, true", productID, got, ok, want) + } + } +} + +func TestCrossPlatformCoverageDingTalkEndpointsFollowSelectedTokenRegion(t *testing.T) { + configDir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", configDir) + mcpURLPath := filepath.Join(configDir, "mcp_url") + + if err := os.WriteFile(mcpURLPath, []byte("https://pre-mcp.dingtalk.io\n"), 0o600); err != nil { + t.Fatalf("WriteFile(mcp_url) error = %v", err) + } + endpoint := "https://pre-mcp-gw.dingtalk.io/server/contact?key=abc" + if got, want := activeDingTalkGatewayEndpointForLoginRegion(endpoint, authpkg.LoginRegionDefault), "https://pre-mcp-gw.dingtalk.com/server/contact?key=abc"; got != want { + t.Fatalf("domestic profile endpoint = %q, want %q", got, want) + } + if got := activeDingTalkGatewayEndpointForLoginRegion(endpoint, authpkg.LoginRegionInternational); got != endpoint { + t.Fatalf("international profile endpoint = %q, want %q", got, endpoint) + } + + if err := os.WriteFile(mcpURLPath, []byte("https://mcp.dingtalk.com\n"), 0o600); err != nil { + t.Fatalf("WriteFile(mcp_url) error = %v", err) + } + if got, want := activeDingTalkGatewayEndpointForLoginRegion("https://mcp-gw.dingtalk.com/server/contact", authpkg.LoginRegionInternational), "https://mcp-gw.dingtalk.io/server/contact"; got != want { + t.Fatalf("international profile endpoint from domestic config = %q, want %q", got, want) + } +} + +func TestCrossPlatformCoverageDingTalkEndpointUsesLoginScopedMCPOverride(t *testing.T) { + configDir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", configDir) + restore := authpkg.PushMCPBaseURLOverride("https://pre-mcp.dingtalk.io") + defer restore() + + got := activeDingTalkGatewayEndpointForLoginRegion( + "https://mcp-gw.dingtalk.com/server/contact", + authpkg.LoginRegionDefault, + ) + want := "https://pre-mcp-gw.dingtalk.io/server/contact" + if got != want { + t.Fatalf("login-scoped endpoint = %q, want %q", got, want) + } +} + func mustFindCommand(t *testing.T, root *cobra.Command, path ...string) *cobra.Command { t.Helper() cmd := root diff --git a/internal/app/runner.go b/internal/app/runner.go index 175f70b9..36e78c1f 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -162,7 +162,7 @@ var ( runnerResolveMultiProfileSelections = resolveMultiProfileSelections runnerResolveProfile = authpkg.ResolveProfile runnerGetCachedRuntimeToken = getCachedRuntimeToken - runnerResolveAuthToken = (*runtimeRunner).resolveAuthToken + runnerResolveAuthSnapshot = (*runtimeRunner).resolveAuthSnapshot runnerPreflightDocDownload = (*runtimeRunner).preflightDocDownload runnerCallTool = (*transport.Client).CallTool runnerStdioEnsureInitialized = (*transport.StdioClient).EnsureInitialized @@ -552,11 +552,16 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, if hasPluginAuth { authToken = pluginAuth.Token } else if !invocation.DryRun && (r.globalFlags == nil || !r.globalFlags.Mock) { - var tokenErr error - authToken, tokenErr = runnerResolveAuthToken(r, ctx) + snapshot, tokenErr := runnerResolveAuthSnapshot(r, ctx) if tokenErr != nil { return executor.Result{}, tokenResolutionError(tokenErr) } + authToken = snapshot.AccessToken + if !hasDirectRuntimeEndpointOverride(invocation.CanonicalProduct) && + isDingTalkMCPGatewayEndpoint(endpoint) && + (snapshot.LoginRegionKnown || authpkg.MCPBaseURLOverride() != "") { + endpoint = activeDingTalkGatewayEndpointForLoginRegion(endpoint, snapshot.LoginRegion) + } } var timeoutSec int @@ -877,21 +882,33 @@ func (r *runtimeRunner) executeStdioInvocationAtEndpoint( } func (r *runtimeRunner) resolveAuthToken(ctx context.Context) (string, error) { - explicitToken := "" - if r != nil && r.globalFlags != nil { - explicitToken = r.globalFlags.Token - } - return resolveRuntimeAuthToken(ctx, explicitToken) -} - -func resolveRuntimeAuthToken(ctx context.Context, explicitToken string) (string, error) { - snapshot, err := runtimeTokenManager.Get(ctx, defaultConfigDir(), explicitToken) + snapshot, err := r.resolveAuthSnapshot(ctx) if err != nil { return "", err } return snapshot.AccessToken, nil } +func (r *runtimeRunner) resolveAuthSnapshot(ctx context.Context) (AccessTokenSnapshot, error) { + explicitToken := "" + if r != nil && r.globalFlags != nil { + explicitToken = r.globalFlags.Token + } + return resolveRuntimeAuthSnapshot(ctx, explicitToken) +} + +func resolveRuntimeAuthToken(ctx context.Context, explicitToken string) (string, error) { + snapshot, err := resolveRuntimeAuthSnapshot(ctx, explicitToken) + if err != nil { + return "", err + } + return snapshot.AccessToken, nil +} + +func resolveRuntimeAuthSnapshot(ctx context.Context, explicitToken string) (AccessTokenSnapshot, error) { + return runtimeTokenManager.Get(ctx, defaultConfigDir(), explicitToken) +} + // getCachedRuntimeToken is kept as the prefetch seam used by runner tests. The // cache itself lives exclusively in TokenManager. func getCachedRuntimeToken(ctx context.Context) (string, error) { diff --git a/internal/app/runner_full_coverage_test.go b/internal/app/runner_full_coverage_test.go index 34e1b0ba..28de556c 100644 --- a/internal/app/runner_full_coverage_test.go +++ b/internal/app/runner_full_coverage_test.go @@ -127,12 +127,14 @@ func TestCrossPlatformCoverageRunnerRemainingRoutingCoverage(t *testing.T) { } func TestCrossPlatformCoverageRunnerRemainingExecutionCoverage(t *testing.T) { + t.Setenv("DWS_CONFIG_DIR", t.TempDir()) oldEdition := edition.Get() oldPreflight := runnerPreflightDocDownload oldCall := runnerCallTool oldHandle := runnerHandlePatAuthCheck oldRetry := runnerRetryWithPatAuthRetry oldCapture := runnerCaptureRuntimeFailure + oldResolveSnapshot := runnerResolveAuthSnapshot t.Cleanup(func() { edition.Override(oldEdition) runnerPreflightDocDownload = oldPreflight @@ -140,6 +142,7 @@ func TestCrossPlatformCoverageRunnerRemainingExecutionCoverage(t *testing.T) { runnerHandlePatAuthCheck = oldHandle runnerRetryWithPatAuthRetry = oldRetry runnerCaptureRuntimeFailure = oldCapture + runnerResolveAuthSnapshot = oldResolveSnapshot }) pluginAuthMu.Lock() @@ -168,6 +171,27 @@ func TestCrossPlatformCoverageRunnerRemainingExecutionCoverage(t *testing.T) { if got, err := r.executeInvocation(context.Background(), "https://example.test", inv); err != nil || !got.Invocation.Implemented { t.Fatalf("default auth execution = %#v, %v", got, err) } + runnerResolveAuthSnapshot = func(*runtimeRunner, context.Context) (AccessTokenSnapshot, error) { + return AccessTokenSnapshot{ + AccessToken: "international-token", + LoginRegion: authpkg.LoginRegionInternational, + LoginRegionKnown: true, + }, nil + } + successfulCall := runnerCallTool + routedEndpoint := "" + runnerCallTool = func(_ *transport.Client, _ context.Context, endpoint, _ string, _ map[string]any) (transport.ToolCallResult, error) { + routedEndpoint = endpoint + return transport.ToolCallResult{Content: map[string]any{"value": 1}}, nil + } + if _, err := r.executeInvocation(context.Background(), "https://mcp-gw.dingtalk.com/server/contact", inv); err != nil { + t.Fatalf("international auth execution = %v", err) + } + if routedEndpoint != "https://mcp-gw.dingtalk.io/server/contact" { + t.Fatalf("international routed endpoint = %q", routedEndpoint) + } + runnerResolveAuthSnapshot = oldResolveSnapshot + runnerCallTool = successfulCall wantErr := errors.New("preflight") runnerPreflightDocDownload = func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error { @@ -362,6 +386,12 @@ func TestCrossPlatformCoverageRunnerRemainingStdioAuthAndHeadersCoverage(t *test if got, err := resolveRuntimeAuthToken(context.Background(), " runtime "); err != nil || got != "runtime" { t.Fatalf("runtime explicit token = %q, %v", got, err) } + edition.Override(&edition.Hooks{TokenProvider: func(context.Context, func() (string, error)) (string, error) { + return "", errors.New("snapshot failed") + }}) + if _, err := r.resolveAuthToken(context.Background()); err == nil || !strings.Contains(err.Error(), "snapshot failed") { + t.Fatalf("resolveAuthToken error = %v", err) + } t.Setenv(envDWSChannel, "channel") edition.Override(&edition.Hooks{ diff --git a/internal/auth/auth_extra_test.go b/internal/auth/auth_extra_test.go index d64d9ee8..bc4909bd 100644 --- a/internal/auth/auth_extra_test.go +++ b/internal/auth/auth_extra_test.go @@ -424,6 +424,87 @@ func TestBuildAuthURLIncludesTargetCorpID(t *testing.T) { } } +func TestCrossPlatformCoverageBuildAuthURLForInternationalRegion(t *testing.T) { + authURL := buildAuthURLForRegion("client-id", "http://127.0.0.1:1234/callback", "", LoginRegionInternational) + if !strings.HasPrefix(authURL, InternationalAuthorizeURL+"?") { + t.Fatalf("auth URL = %s, want international authorize host", authURL) + } +} + +func TestCrossPlatformCoverageNotEnabledHTMLUsesRegionAwareAuthorizeURL(t *testing.T) { + if !strings.Contains(notEnabledHTML, "status.authorizeUrl") { + t.Fatal("not-enabled page must read the authorize URL from the regional login status") + } + if strings.Contains(notEnabledHTML, `"https://login.dingtalk.com/oauth2/auth?client_id="`) { + t.Fatal("not-enabled page must not hard-code the domestic authorize URL") + } +} + +func TestCrossPlatformCoverageLoginRegionEndpointDefaults(t *testing.T) { + if got := AuthorizeURLForLoginRegion(LoginRegionDefault); got != AuthorizeURL { + t.Fatalf("default authorize URL = %q, want %q", got, AuthorizeURL) + } + if got := DeviceBaseURLForLoginRegion(LoginRegionDefault); got != DefaultDeviceBaseURL { + t.Fatalf("default device base URL = %q, want %q", got, DefaultDeviceBaseURL) + } + if got := UserAccessTokenURLForLoginRegion(LoginRegionInternational); got != InternationalUserAccessTokenURL { + t.Fatalf("international user access token URL = %q, want %q", got, InternationalUserAccessTokenURL) + } + if got := MCPBaseURLForLoginRegion(LoginRegionInternational); got != InternationalMCPBaseURL { + t.Fatalf("international MCP base URL = %q, want %q", got, InternationalMCPBaseURL) + } + if got := DeviceBaseURLForLoginRegion(LoginRegionInternational); got != InternationalDeviceBaseURL { + t.Fatalf("international device base URL = %q, want %q", got, InternationalDeviceBaseURL) + } +} + +func TestCrossPlatformCoverageOAuthProviderLoginRegionHelpers(t *testing.T) { + var nilProvider *OAuthProvider + if got := nilProvider.loginRegion(); got != LoginRegionDefault { + t.Fatalf("nil provider login region = %q", got) + } + nilProvider.useTokenLoginRegion(&TokenData{LoginRegion: string(LoginRegionInternational)}) + nilProvider.applyLoginRegionToToken(&TokenData{}) + + provider := &OAuthProvider{} + provider.useTokenLoginRegion(nil) + provider.useTokenLoginRegion(&TokenData{LoginRegion: string(LoginRegionInternational)}) + if provider.LoginRegion != LoginRegionInternational { + t.Fatalf("provider login region = %q, want international", provider.LoginRegion) + } + provider.useTokenLoginRegion(&TokenData{LoginRegion: string(LoginRegionDefault)}) + provider.applyLoginRegionToToken(nil) + token := &TokenData{} + provider.applyLoginRegionToToken(token) + if token.LoginRegion != string(LoginRegionInternational) { + t.Fatalf("token login region = %q, want international", token.LoginRegion) + } +} + +func TestCrossPlatformCoverageMCPBaseURLOverrideAffectsInternationalRegion(t *testing.T) { + restore := PushMCPBaseURLOverride("https://pre-mcp.dingtalk.io/") + defer restore() + + if got := MCPBaseURLForLoginRegion(LoginRegionInternational); got != "https://pre-mcp.dingtalk.io" { + t.Fatalf("international MCP base URL = %q, want override", got) + } +} + +func TestCrossPlatformCoverageLoginBaseURLOverrideAffectsInternationalRegion(t *testing.T) { + restore := PushLoginBaseURLOverride("https://pre-login.dingtalk.io/") + defer restore() + + if got := DeviceBaseURLForLoginRegion(LoginRegionInternational); got != "https://pre-login.dingtalk.io" { + t.Fatalf("international device base URL = %q, want override", got) + } + if got := AuthorizeURLForLoginRegion(LoginRegionInternational); got != "https://pre-login.dingtalk.io/oauth2/auth" { + t.Fatalf("international authorize URL = %q, want override", got) + } + if got := UserAccessTokenURLForLoginRegion(LoginRegionInternational); got != "https://pre-login.dingtalk.io/v1.0/oauth2/userAccessToken" { + t.Fatalf("international user access token URL = %q, want override", got) + } +} + func buildTokenDataFromResponse(resp tokenResponse) *TokenData { if resp.AccessToken == "" { return nil diff --git a/internal/auth/cli_auth_check_test.go b/internal/auth/cli_auth_check_test.go index 6ed68644..802035ea 100644 --- a/internal/auth/cli_auth_check_test.go +++ b/internal/auth/cli_auth_check_test.go @@ -14,10 +14,12 @@ package auth import ( + "bytes" "context" "encoding/json" "fmt" "io" + "log/slog" "net/http" "net/http/httptest" "os" @@ -206,6 +208,105 @@ func TestCheckCLIAuthEnabled_Enabled(t *testing.T) { t.Logf("✅ Normal enabled response: success=%v, enabled=%v", status.Success, status.Result.CLIAuthEnabled) } +func TestCrossPlatformCoverageCheckCLIAuthEnabledTraceHeadersAndDebugLog(t *testing.T) { + t.Setenv("DINGTALK_TRACE_ID", "trace-for-cli-auth-test") + applyCLIAuthTraceHeaders(nil, "trace-for-cli-auth-test") + applyCLIAuthTraceHeaders(httptest.NewRequest(http.MethodGet, "https://example.com", nil), "") + + var logBuf bytes.Buffer + previousLogger := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + defer slog.SetDefault(previousLogger) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("EagleEye-TraceId"); got != "trace-for-cli-auth-test" { + t.Errorf("EagleEye-TraceId = %q, want trace-for-cli-auth-test", got) + } + if got := r.Header.Get("X-Dingtalk-Trace-Id"); got != "trace-for-cli-auth-test" { + t.Errorf("X-Dingtalk-Trace-Id = %q, want trace-for-cli-auth-test", got) + } + w.Header().Set("EagleEye-TraceId", "server-trace-001") + w.Header().Set("EagleEye-RpcId", "rpc-001") + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(CLIAuthStatus{ + Success: true, + Result: &CLIAuthResult{CLIAuthEnabled: true}, + }) + })) + defer srv.Close() + + configDir := setupMCPConfigDir(t, srv.URL) + p := &OAuthProvider{configDir: configDir, httpClient: srv.Client()} + + status, err := p.CheckCLIAuthEnabled(context.Background(), "sensitive-token") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if status.Result == nil || !status.Result.CLIAuthEnabled { + t.Fatal("expected CLIAuthEnabled=true") + } + + logs := logBuf.String() + for _, want := range []string{ + "auth.cli_auth_enabled.request", + "auth.cli_auth_enabled.response", + "trace-for-cli-auth-test", + "server-trace-001", + "rpc-001", + } { + if !strings.Contains(logs, want) { + t.Fatalf("debug logs missing %q, got: %s", want, logs) + } + } + if strings.Contains(logs, "sensitive-token") { + t.Fatalf("debug logs leaked access token: %s", logs) + } +} + +func TestCrossPlatformCoverageInternationalLoginRegionRequestWrappers(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case ClientIDPath: + _ = json.NewEncoder(w).Encode(ClientIDResponse{Success: true, Result: "international-client"}) + case SuperAdminPath: + _ = json.NewEncoder(w).Encode(SuperAdminResponse{Success: true, Result: []SuperAdmin{{StaffID: "admin"}}}) + case SendCliAuthApplyPath: + _ = json.NewEncoder(w).Encode(SendApplyResponse{Success: true, Result: true}) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + oldClient := oauthHTTPClient + oauthHTTPClient = server.Client() + restoreBaseURL := PushMCPBaseURLOverride(server.URL) + t.Cleanup(func() { + restoreBaseURL() + oauthHTTPClient = oldClient + }) + + ctx := context.Background() + for name, fetch := range map[string]func() (string, error){ + "device": func() (string, error) { return deviceFetchClientIDForLoginRegion(ctx, LoginRegionInternational) }, + "oauth": func() (string, error) { return oauthFetchClientIDForLoginRegion(ctx, LoginRegionInternational) }, + } { + if got, err := fetch(); err != nil || got != "international-client" { + t.Fatalf("%s client ID = %q, %v", name, got, err) + } + } + if got, err := deviceGetAdminsForLoginRegion(ctx, "token", LoginRegionInternational); err != nil || !got.Success { + t.Fatalf("device admins = %#v, %v", got, err) + } + if got, err := oauthGetAdminsForLoginRegion(ctx, "token", LoginRegionInternational); err != nil || !got.Success { + t.Fatalf("OAuth admins = %#v, %v", got, err) + } + if got, err := oauthSendApplyForLoginRegion(ctx, "token", "admin", LoginRegionInternational); err != nil || !got.Success { + t.Fatalf("OAuth apply = %#v, %v", got, err) + } +} + func TestCheckCLIAuthEnabled_Disabled(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/internal/auth/coverage_edges_test.go b/internal/auth/coverage_edges_test.go index d850988a..4fa0a166 100644 --- a/internal/auth/coverage_edges_test.go +++ b/internal/auth/coverage_edges_test.go @@ -22,6 +22,7 @@ import ( "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) @@ -1211,7 +1212,13 @@ func TestCrossPlatformCoveragePortableAuthBundleCoverageEdges(t *testing.T) { if err := os.Symlink(filepath.Join(keyDir, "dek"), filepath.Join(keyDir, "link")); err != nil { t.Fatal(err) } - for name, value := range map[string]string{"app.json": "{}", profilesJSONFile: "{}", "mcp_url": "https://mcp.test", "terminal_url": "https://terminal.test"} { + for name, value := range map[string]string{ + "app.json": "{}", + profilesJSONFile: "{}", + "mcp_url": "https://mcp.test", + config.ManagedMCPURLRegionFileName: "https://mcp.test", + "terminal_url": "https://terminal.test", + } { if err := os.WriteFile(filepath.Join(configDir, name), []byte(value), 0o600); err != nil { t.Fatal(err) } @@ -1234,6 +1241,9 @@ func TestCrossPlatformCoveragePortableAuthBundleCoverageEdges(t *testing.T) { if _, err := os.Stat(filepath.Join(importDir, "app.json")); err != nil { t.Fatal("config file was not imported") } + if _, err := os.Stat(filepath.Join(importDir, config.ManagedMCPURLRegionFileName)); err != nil { + t.Fatal("managed MCP region marker was not imported") + } if _, err := os.Stat(filepath.Join(keychain.StorageDir(keychain.Service), keychain.AccountToken+".enc")); err != nil { t.Fatal("encrypted token was not imported") } diff --git a/internal/auth/device_flow.go b/internal/auth/device_flow.go index c711949a..a990072d 100644 --- a/internal/auth/device_flow.go +++ b/internal/auth/device_flow.go @@ -74,6 +74,20 @@ var ( } ) +func deviceFetchClientIDForLoginRegion(ctx context.Context, region LoginRegion) (string, error) { + if region.IsInternational() { + return FetchClientIDFromMCPForLoginRegion(ctx, region) + } + return deviceFetchClientID(ctx) +} + +func deviceGetAdminsForLoginRegion(ctx context.Context, accessToken string, region LoginRegion) (*SuperAdminResponse, error) { + if region.IsInternational() { + return GetSuperAdminsForLoginRegion(ctx, accessToken, region) + } + return deviceGetAdmins(ctx, accessToken) +} + type DeviceFlowProvider struct { configDir string clientID string @@ -85,6 +99,7 @@ type DeviceFlowProvider struct { httpClient *http.Client NoBrowser bool IdentityEnricher func(context.Context, *TokenData) error + LoginRegion LoginRegion } func NewDeviceFlowProvider(configDir string, logger *slog.Logger) *DeviceFlowProvider { @@ -104,6 +119,12 @@ func (p *DeviceFlowProvider) SetBaseURL(baseURL string) { p.baseURL = strings.TrimRight(baseURL, "/") } +func (p *DeviceFlowProvider) SetLoginRegion(region LoginRegion) { + p.LoginRegion = region + p.baseURL = DeviceBaseURLForLoginRegion(region) + p.terminalBaseURL = MCPBaseURLForLoginRegion(region) +} + // SetTerminalBaseURL sets the terminal API base URL for device flow polling. func (p *DeviceFlowProvider) SetTerminalBaseURL(baseURL string) { p.terminalBaseURL = strings.TrimRight(baseURL, "/") @@ -213,7 +234,7 @@ func (p *DeviceFlowProvider) Login(ctx context.Context) (*TokenData, error) { if p.logger != nil { p.logger.Debug("fetching client ID from MCP server (device flow always re-fetches)") } - mcpClientID, mcpErr := deviceFetchClientID(ctx) + mcpClientID, mcpErr := deviceFetchClientIDForLoginRegion(ctx, p.LoginRegion) if mcpErr != nil { return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr) } @@ -272,6 +293,7 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token clientID: p.clientID, logger: p.logger, IdentityEnricher: p.IdentityEnricher, + LoginRegion: p.LoginRegion, } tokenData, err := deviceExchangeCode(oauthProvider, ctx, tokenResult.AuthCode) if err != nil { @@ -331,7 +353,7 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token _, _ = fmt.Fprintln(p.output(), i18n.T(" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。")) _, _ = fmt.Fprintln(p.output(), "") - admins, adminErr := deviceGetAdmins(ctx, tokenData.AccessToken) + admins, adminErr := deviceGetAdminsForLoginRegion(ctx, tokenData.AccessToken, p.LoginRegion) if adminErr == nil && admins.Success && len(admins.Result) > 0 { maxAdmins := 3 if len(admins.Result) < maxAdmins { diff --git a/internal/auth/device_flow_test.go b/internal/auth/device_flow_test.go index 0abc4730..df517041 100644 --- a/internal/auth/device_flow_test.go +++ b/internal/auth/device_flow_test.go @@ -90,6 +90,17 @@ func TestRequestDeviceCodeSuccess(t *testing.T) { } } +func TestCrossPlatformCoverageDeviceFlowSetLoginRegionUsesInternationalBaseURL(t *testing.T) { + provider := NewDeviceFlowProvider(t.TempDir(), newDeviceFlowTestLogger()) + provider.SetLoginRegion(LoginRegionInternational) + if provider.baseURL != InternationalDeviceBaseURL { + t.Fatalf("baseURL = %q, want %q", provider.baseURL, InternationalDeviceBaseURL) + } + if provider.terminalBaseURL != InternationalMCPBaseURL { + t.Fatalf("terminalBaseURL = %q, want %q", provider.terminalBaseURL, InternationalMCPBaseURL) + } +} + func TestWaitForAuthorizationSucceedsAfterPending(t *testing.T) { t.Parallel() diff --git a/internal/auth/endpoints.go b/internal/auth/endpoints.go index 098297f2..31a204cc 100644 --- a/internal/auth/endpoints.go +++ b/internal/auth/endpoints.go @@ -50,9 +50,15 @@ const ( // AuthorizeURL is the DingTalk OAuth authorization page. AuthorizeURL = "https://login.dingtalk.com/oauth2/auth" + // InternationalAuthorizeURL is the DingTalk international OAuth authorization page. + InternationalAuthorizeURL = "https://login.dingtalk.io/oauth2/auth" + // UserAccessTokenURL exchanges an authorization code for user tokens. UserAccessTokenURL = "https://api.dingtalk.com/v1.0/oauth2/userAccessToken" + // InternationalUserAccessTokenURL exchanges authorization codes for international user tokens. + InternationalUserAccessTokenURL = "https://api.dingtalk.io/v1.0/oauth2/userAccessToken" + // UserInfoURL fetches the authenticated user's profile. UserInfoURL = "https://api.dingtalk.com/v1.0/contact/users/me" @@ -75,6 +81,9 @@ const ( // DefaultDeviceBaseURL is the login server base URL for device flow. DefaultDeviceBaseURL = "https://login.dingtalk.com" + // InternationalDeviceBaseURL is the international login server base URL for device flow. + InternationalDeviceBaseURL = "https://login.dingtalk.io" + // DeviceCodePath requests a device_code and user_code. DeviceCodePath = "/oauth2/device/code.json" @@ -96,11 +105,12 @@ const ( LogoutContinueURL = "https://login.dingtalk.com" // MCP API endpoints for CLI authorization management. - DefaultMCPBaseURL = config.DefaultMCPBaseURL - CLIAuthEnabledPath = "/cli/cliAuthEnabled" - SuperAdminPath = "/cli/superAdmin" - SendCliAuthApplyPath = "/cli/sendCliAuthApply" - ClientIDPath = "/cli/clientId" + DefaultMCPBaseURL = config.DefaultMCPBaseURL + InternationalMCPBaseURL = "https://mcp.dingtalk.io" + CLIAuthEnabledPath = "/cli/cliAuthEnabled" + SuperAdminPath = "/cli/superAdmin" + SendCliAuthApplyPath = "/cli/sendCliAuthApply" + ClientIDPath = "/cli/clientId" // MCP OAuth endpoints (used when clientId is fetched from MCP). MCPOAuthTokenPath = "/oauth2/getToken" @@ -114,6 +124,57 @@ const ( AppAccessTokenURL = "https://api.dingtalk.com/v1.0/oauth2/accessToken" ) +type LoginRegion string + +const ( + LoginRegionDefault LoginRegion = "" + LoginRegionInternational LoginRegion = "international" +) + +func (r LoginRegion) IsInternational() bool { + return r == LoginRegionInternational +} + +func AuthorizeURLForLoginRegion(region LoginRegion) string { + if override := LoginBaseURLOverride(); override != "" { + return override + "/oauth2/auth" + } + if region.IsInternational() { + return InternationalAuthorizeURL + } + return AuthorizeURL +} + +func DeviceBaseURLForLoginRegion(region LoginRegion) string { + if override := LoginBaseURLOverride(); override != "" { + return override + } + if region.IsInternational() { + return InternationalDeviceBaseURL + } + return DefaultDeviceBaseURL +} + +func MCPBaseURLForLoginRegion(region LoginRegion) string { + if override := MCPBaseURLOverride(); override != "" { + return override + } + if region.IsInternational() { + return InternationalMCPBaseURL + } + return GetMCPBaseURL() +} + +func UserAccessTokenURLForLoginRegion(region LoginRegion) string { + if override := LoginBaseURLOverride(); override != "" { + return override + "/v1.0/oauth2/userAccessToken" + } + if region.IsInternational() { + return InternationalUserAccessTokenURL + } + return UserAccessTokenURL +} + // GetTerminalBaseURL returns the terminal base URL with priority: // 1. ~/.dws/terminal_url file content (for pre-release environment) // 2. Default value (https://open-dev.dingtalk.com) @@ -146,8 +207,54 @@ var ( // clientIDFromMCP indicates whether the clientID was fetched from MCP server. // When true, MCP OAuth endpoints should be used instead of direct DingTalk API. clientIDFromMCP bool + + loginBaseURLMu sync.RWMutex + loginBaseURLOverride string + mcpBaseURLMu sync.RWMutex + mcpBaseURLOverride string ) +// PushLoginBaseURLOverride sets a process-local DingTalk login base URL +// override and returns a restore function. +func PushLoginBaseURLOverride(baseURL string) func() { + loginBaseURLMu.Lock() + previous := loginBaseURLOverride + loginBaseURLOverride = strings.TrimRight(strings.TrimSpace(baseURL), "/") + loginBaseURLMu.Unlock() + return func() { + loginBaseURLMu.Lock() + loginBaseURLOverride = previous + loginBaseURLMu.Unlock() + } +} + +func LoginBaseURLOverride() string { + loginBaseURLMu.RLock() + defer loginBaseURLMu.RUnlock() + return loginBaseURLOverride +} + +// PushMCPBaseURLOverride sets a process-local MCP base URL override and returns +// a restore function. It is intended for one command invocation, such as +// pre-release smoke testing. +func PushMCPBaseURLOverride(baseURL string) func() { + mcpBaseURLMu.Lock() + previous := mcpBaseURLOverride + mcpBaseURLOverride = strings.TrimRight(strings.TrimSpace(baseURL), "/") + mcpBaseURLMu.Unlock() + return func() { + mcpBaseURLMu.Lock() + mcpBaseURLOverride = previous + mcpBaseURLMu.Unlock() + } +} + +func MCPBaseURLOverride() string { + mcpBaseURLMu.RLock() + defer mcpBaseURLMu.RUnlock() + return mcpBaseURLOverride +} + // SetClientIDFromMCP sets the clientID fetched from MCP server and marks it as MCP-sourced. func SetClientIDFromMCP(id string) { clientMu.Lock() diff --git a/internal/auth/oauth_helpers.go b/internal/auth/oauth_helpers.go index cc30a4a3..fe2c4728 100644 --- a/internal/auth/oauth_helpers.go +++ b/internal/auth/oauth_helpers.go @@ -20,6 +20,7 @@ import ( "fmt" "html" "io" + "log/slog" "net/http" "net/url" "os" @@ -28,6 +29,7 @@ import ( "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" + "github.com/google/uuid" ) var ( @@ -51,7 +53,7 @@ func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenDa "code": code, "grantType": "authorization_code", } - resp, err := p.postJSON(ctx, UserAccessTokenURL, body) + resp, err := p.postJSON(ctx, UserAccessTokenURLForLoginRegion(p.loginRegion()), body) if err != nil { return nil, err } @@ -62,6 +64,7 @@ func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenDa // Snapshot credentials used for this token (for refresh) data.ClientID = clientID data.Source = resolveCredentialSource() + p.applyLoginRegionToToken(data) // Save clientSecret for future refresh (even if env changes) if err := oauthSaveClientSecret(clientID, clientSecret); err != nil { // Log warning but don't fail login @@ -91,11 +94,36 @@ func ExchangeCodeForToken(ctx context.Context, configDir, code string) (*TokenDa return data, nil } +func (p *OAuthProvider) loginRegion() LoginRegion { + if p == nil { + return LoginRegionDefault + } + return p.LoginRegion +} + +func (p *OAuthProvider) useTokenLoginRegion(data *TokenData) { + if p == nil || p.LoginRegion != LoginRegionDefault || data == nil { + return + } + if region := LoginRegion(strings.TrimSpace(data.LoginRegion)); region != LoginRegionDefault { + p.LoginRegion = region + } +} + +func (p *OAuthProvider) applyLoginRegionToToken(data *TokenData) { + if data == nil { + return + } + if region := p.loginRegion(); region != LoginRegionDefault { + data.LoginRegion = string(region) + } +} + // exchangeCodeViaMCP exchanges auth code for token via MCP proxy. // This is used when client secret is not available (server-side secret management). func (p *OAuthProvider) exchangeCodeViaMCP(ctx context.Context, code string) (*TokenData, error) { clientID := ClientID() - url := GetMCPBaseURL() + MCPOAuthTokenPath + url := MCPBaseURLForLoginRegion(p.loginRegion()) + MCPOAuthTokenPath body := map[string]string{ "clientId": clientID, "authCode": code, @@ -112,6 +140,7 @@ func (p *OAuthProvider) exchangeCodeViaMCP(ctx context.Context, code string) (*T // Snapshot credentials used for this token (for refresh) data.ClientID = clientID data.Source = "mcp" + p.applyLoginRegionToToken(data) // MCP mode doesn't need to save clientSecret (server-side managed) return data, nil } @@ -120,8 +149,10 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token // Use stored Source to determine refresh path (not current runtime state) // This ensures refresh works even if environment variables changed since login if data.Source == "mcp" { + p.useTokenLoginRegion(data) return p.refreshViaMCP(ctx, data) } + p.useTokenLoginRegion(data) // Direct mode: use stored clientId and load saved clientSecret clientID := data.ClientID @@ -145,7 +176,7 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token "refreshToken": data.RefreshToken, "grantType": "refresh_token", } - resp, err := p.postJSON(ctx, UserAccessTokenURL, body) + resp, err := p.postJSON(ctx, UserAccessTokenURLForLoginRegion(p.loginRegion()), body) if err != nil { return nil, err } @@ -156,6 +187,7 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token // Preserve original credentials info updated.ClientID = data.ClientID updated.Source = data.Source + updated.LoginRegion = data.LoginRegion updated.PersistentCode = data.PersistentCode updated.CorpID = data.CorpID updated.UserID = data.UserID @@ -185,7 +217,7 @@ func (p *OAuthProvider) refreshViaMCP(ctx context.Context, data *TokenData) (*To return nil, fmt.Errorf("无法刷新 token: 缺少 clientId,请重新登录") } - url := GetMCPBaseURL() + MCPRefreshTokenPath + url := MCPBaseURLForLoginRegion(p.loginRegion()) + MCPRefreshTokenPath body := map[string]string{ "clientId": clientID, "refreshToken": data.RefreshToken, @@ -202,6 +234,7 @@ func (p *OAuthProvider) refreshViaMCP(ctx context.Context, data *TokenData) (*To // Preserve original credentials info updated.ClientID = data.ClientID updated.Source = data.Source + updated.LoginRegion = data.LoginRegion updated.PersistentCode = data.PersistentCode updated.CorpID = data.CorpID updated.UserID = data.UserID @@ -371,6 +404,10 @@ func firstNonEmpty(values ...string) string { } func buildAuthURL(clientID, redirectURI, targetCorpID string) string { + return buildAuthURLForRegion(clientID, redirectURI, targetCorpID, LoginRegionDefault) +} + +func buildAuthURLForRegion(clientID, redirectURI, targetCorpID string, region LoginRegion) string { params := url.Values{ "client_id": {clientID}, "redirect_uri": {redirectURI}, @@ -381,7 +418,7 @@ func buildAuthURL(clientID, redirectURI, targetCorpID string) string { if targetCorpID = strings.TrimSpace(targetCorpID); targetCorpID != "" { params.Set("corpId", targetCorpID) } - return AuthorizeURL + "?" + params.Encode() + return AuthorizeURLForLoginRegion(region) + "?" + params.Encode() } const successHTML = ` @@ -937,14 +974,15 @@ const notEnabledHTML = ` clientId = status.clientId || ""; applySent = status.applySent || false; selectedAdminId = status.selectedAdminId || ""; + const authorizeUrl = status.authorizeUrl || ""; - if (clientId) { + if (clientId && authorizeUrl) { const port = location.port; const redirectUri = encodeURIComponent( "http://127.0.0.1:" + port + "/callback" ); backLink.href = - "https://login.dingtalk.com/oauth2/auth?client_id=" + + authorizeUrl + "?client_id=" + clientId + "&prompt=consent&redirect_uri=" + redirectUri + @@ -1398,6 +1436,7 @@ const mcpRequestMaxRetries = 3 // false negatives caused by momentary network issues. func (p *OAuthProvider) CheckCLIAuthEnabled(ctx context.Context, accessToken string) (*CLIAuthStatus, error) { var lastErr error + traceID := cliAuthTraceID() for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ { if attempt > 0 { select { @@ -1406,7 +1445,7 @@ func (p *OAuthProvider) CheckCLIAuthEnabled(ctx context.Context, accessToken str case <-oauthRetryAfter(time.Duration(attempt) * time.Second): } } - status, err := p.doCheckCLIAuthEnabled(ctx, accessToken) + status, err := p.doCheckCLIAuthEnabledAttempt(ctx, accessToken, attempt+1, traceID) if err == nil { return status, nil } @@ -1416,16 +1455,27 @@ func (p *OAuthProvider) CheckCLIAuthEnabled(ctx context.Context, accessToken str } func (p *OAuthProvider) doCheckCLIAuthEnabled(ctx context.Context, accessToken string) (*CLIAuthStatus, error) { - url := GetMCPBaseURL() + CLIAuthEnabledPath + return p.doCheckCLIAuthEnabledAttempt(ctx, accessToken, 1, cliAuthTraceID()) +} + +func (p *OAuthProvider) doCheckCLIAuthEnabledAttempt(ctx context.Context, accessToken string, attempt int, traceID string) (*CLIAuthStatus, error) { + url := MCPBaseURLForLoginRegion(p.loginRegion()) + CLIAuthEnabledPath req, err := oauthNewRequest(ctx, http.MethodGet, url, nil) if err != nil { return nil, fmt.Errorf("creating request: %w", err) } req.Header.Set("x-user-access-token", accessToken) + applyCLIAuthTraceHeaders(req, traceID) if ch := os.Getenv("DWS_CHANNEL"); ch != "" { req.Header.Set("x-dws-channel", ch) } applyEditionEnterpriseCredentialHeaders(req) + slog.Debug("auth.cli_auth_enabled.request", + "attempt", attempt, + "url", url, + "trace_id", traceID, + "channel", os.Getenv("DWS_CHANNEL"), + ) client := p.httpClient if client == nil { @@ -1433,9 +1483,23 @@ func (p *OAuthProvider) doCheckCLIAuthEnabled(ctx context.Context, accessToken s } resp, err := client.Do(req) if err != nil { + slog.Debug("auth.cli_auth_enabled.error", + "attempt", attempt, + "url", url, + "trace_id", traceID, + "error", err, + ) return nil, fmt.Errorf("sending request: %w", err) } defer resp.Body.Close() + slog.Debug("auth.cli_auth_enabled.response", + "attempt", attempt, + "url", url, + "status", resp.StatusCode, + "trace_id", traceID, + "response_trace_id", cliAuthResponseTraceID(resp.Header), + "eagleeye_rpc_id", resp.Header.Get("EagleEye-RpcId"), + ) data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize)) if err != nil { @@ -1449,9 +1513,42 @@ func (p *OAuthProvider) doCheckCLIAuthEnabled(ctx context.Context, accessToken s return &status, nil } +func cliAuthTraceID() string { + if traceID := strings.TrimSpace(os.Getenv("DINGTALK_TRACE_ID")); traceID != "" { + return traceID + } + return strings.ReplaceAll(uuid.NewString(), "-", "") +} + +func applyCLIAuthTraceHeaders(req *http.Request, traceID string) { + if req == nil || traceID == "" { + return + } + req.Header.Set("EagleEye-TraceId", traceID) + req.Header.Set("X-Dingtalk-Trace-Id", traceID) +} + +func cliAuthResponseTraceID(headers http.Header) string { + for _, key := range []string{ + "EagleEye-TraceId", + "X-Trace-Id", + "X-Request-Id", + "X-Dingtalk-Trace-Id", + } { + if value := strings.TrimSpace(headers.Get(key)); value != "" { + return value + } + } + return "" +} + // GetSuperAdmins fetches the list of corp super admins. // It retries up to mcpRequestMaxRetries times on transient errors. func GetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminResponse, error) { + return GetSuperAdminsForLoginRegion(ctx, accessToken, LoginRegionDefault) +} + +func GetSuperAdminsForLoginRegion(ctx context.Context, accessToken string, region LoginRegion) (*SuperAdminResponse, error) { var lastErr error for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ { if attempt > 0 { @@ -1461,7 +1558,7 @@ func GetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminRespons case <-oauthRetryAfter(time.Duration(attempt) * time.Second): } } - result, err := doGetSuperAdmins(ctx, accessToken) + result, err := doGetSuperAdminsForLoginRegion(ctx, accessToken, region) if err == nil { return result, nil } @@ -1471,7 +1568,11 @@ func GetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminRespons } func doGetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminResponse, error) { - url := GetMCPBaseURL() + SuperAdminPath + return doGetSuperAdminsForLoginRegion(ctx, accessToken, LoginRegionDefault) +} + +func doGetSuperAdminsForLoginRegion(ctx context.Context, accessToken string, region LoginRegion) (*SuperAdminResponse, error) { + url := MCPBaseURLForLoginRegion(region) + SuperAdminPath req, err := oauthNewRequest(ctx, http.MethodGet, url, nil) if err != nil { return nil, fmt.Errorf("creating request: %w", err) @@ -1500,6 +1601,10 @@ func doGetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminRespo // SendCliAuthApply sends a CLI auth apply request to the specified admin. // It retries up to mcpRequestMaxRetries times on transient errors. func SendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*SendApplyResponse, error) { + return SendCliAuthApplyForLoginRegion(ctx, accessToken, adminStaffID, LoginRegionDefault) +} + +func SendCliAuthApplyForLoginRegion(ctx context.Context, accessToken, adminStaffID string, region LoginRegion) (*SendApplyResponse, error) { var lastErr error for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ { if attempt > 0 { @@ -1509,7 +1614,7 @@ func SendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*S case <-oauthRetryAfter(time.Duration(attempt) * time.Second): } } - result, err := doSendCliAuthApply(ctx, accessToken, adminStaffID) + result, err := doSendCliAuthApplyForLoginRegion(ctx, accessToken, adminStaffID, region) if err == nil { return result, nil } @@ -1519,7 +1624,11 @@ func SendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*S } func doSendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*SendApplyResponse, error) { - url := GetMCPBaseURL() + SendCliAuthApplyPath + "?adminStaffId=" + adminStaffID + return doSendCliAuthApplyForLoginRegion(ctx, accessToken, adminStaffID, LoginRegionDefault) +} + +func doSendCliAuthApplyForLoginRegion(ctx context.Context, accessToken, adminStaffID string, region LoginRegion) (*SendApplyResponse, error) { + url := MCPBaseURLForLoginRegion(region) + SendCliAuthApplyPath + "?adminStaffId=" + adminStaffID req, err := oauthNewRequest(ctx, http.MethodGet, url, nil) if err != nil { return nil, fmt.Errorf("creating request: %w", err) @@ -1557,6 +1666,10 @@ type ClientIDResponse struct { // This is used when no client ID is provided via flags, config, or env vars. // It retries up to mcpRequestMaxRetries times on transient errors. func FetchClientIDFromMCP(ctx context.Context) (string, error) { + return FetchClientIDFromMCPForLoginRegion(ctx, LoginRegionDefault) +} + +func FetchClientIDFromMCPForLoginRegion(ctx context.Context, region LoginRegion) (string, error) { var lastErr error for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ { if attempt > 0 { @@ -1566,7 +1679,7 @@ func FetchClientIDFromMCP(ctx context.Context) (string, error) { case <-oauthRetryAfter(time.Duration(attempt) * time.Second): } } - id, err := doFetchClientIDFromMCP(ctx) + id, err := doFetchClientIDFromMCPForLoginRegion(ctx, region) if err == nil { return id, nil } @@ -1576,7 +1689,11 @@ func FetchClientIDFromMCP(ctx context.Context) (string, error) { } func doFetchClientIDFromMCP(ctx context.Context) (string, error) { - url := GetMCPBaseURL() + ClientIDPath + return doFetchClientIDFromMCPForLoginRegion(ctx, LoginRegionDefault) +} + +func doFetchClientIDFromMCPForLoginRegion(ctx context.Context, region LoginRegion) (string, error) { + url := MCPBaseURLForLoginRegion(region) + ClientIDPath req, err := oauthNewRequest(ctx, http.MethodGet, url, nil) if err != nil { return "", fmt.Errorf("creating request: %w", err) diff --git a/internal/auth/oauth_provider.go b/internal/auth/oauth_provider.go index 2396f650..da6b66df 100644 --- a/internal/auth/oauth_provider.go +++ b/internal/auth/oauth_provider.go @@ -68,6 +68,27 @@ var ( oauthSleep = time.Sleep ) +func oauthFetchClientIDForLoginRegion(ctx context.Context, region LoginRegion) (string, error) { + if region.IsInternational() { + return FetchClientIDFromMCPForLoginRegion(ctx, region) + } + return oauthFetchClientID(ctx) +} + +func oauthGetAdminsForLoginRegion(ctx context.Context, accessToken string, region LoginRegion) (*SuperAdminResponse, error) { + if region.IsInternational() { + return GetSuperAdminsForLoginRegion(ctx, accessToken, region) + } + return oauthGetAdmins(ctx, accessToken) +} + +func oauthSendApplyForLoginRegion(ctx context.Context, accessToken, adminStaffID string, region LoginRegion) (*SendApplyResponse, error) { + if region.IsInternational() { + return SendCliAuthApplyForLoginRegion(ctx, accessToken, adminStaffID, region) + } + return oauthSendApply(ctx, accessToken, adminStaffID) +} + // OAuthProvider handles the DingTalk OAuth 2.0 authorization code flow. type OAuthProvider struct { configDir string @@ -80,6 +101,7 @@ type OAuthProvider struct { // IdentityEnricher resolves userId/userName/corpName while the freshly // exchanged access token is still only in memory. IdentityEnricher func(context.Context, *TokenData) error + LoginRegion LoginRegion } // NewOAuthProvider creates a new OAuth provider. @@ -173,7 +195,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro if p.logger != nil { p.logger.Debug("fetching client ID from MCP server (OAuth flow always re-fetches)") } - mcpClientID, mcpErr := oauthFetchClientID(ctx) + mcpClientID, mcpErr := oauthFetchClientIDForLoginRegion(ctx, p.LoginRegion) if mcpErr != nil { return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr) } @@ -401,7 +423,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro _, _ = w.Write([]byte(`{"success":false,"errorMsg":"授权尚未完成"}`)) return } - result, err := oauthGetAdmins(ctx, token.AccessToken) + result, err := oauthGetAdminsForLoginRegion(ctx, token.AccessToken, p.LoginRegion) if err != nil { _, _ = fmt.Fprintf(w, `{"success":false,"errorMsg":"%s"}`, err.Error()) return @@ -425,7 +447,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro _, _ = w.Write([]byte(`{"success":false,"errorMsg":"授权尚未完成"}`)) return } - result, err := oauthSendApply(ctx, token.AccessToken, adminStaffID) + result, err := oauthSendApplyForLoginRegion(ctx, token.AccessToken, adminStaffID, p.LoginRegion) if err != nil { _, _ = fmt.Fprintf(w, `{"success":false,"errorMsg":"%s"}`, err.Error()) return @@ -448,7 +470,13 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro applySent := callbackApplySent selectedAdminId := callbackSelectedAdminId callbackTokenMu.Unlock() - _, _ = fmt.Fprintf(w, `{"clientId":"%s","applySent":%t,"selectedAdminId":"%s"}`, p.clientID, applySent, selectedAdminId) + data, _ := json.Marshal(map[string]any{ + "clientId": p.clientID, + "authorizeUrl": AuthorizeURLForLoginRegion(p.LoginRegion), + "applySent": applySent, + "selectedAdminId": selectedAdminId, + }) + _, _ = w.Write(data) }) // API endpoint: check CLI auth enabled status @@ -491,7 +519,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro _ = server.Shutdown(shutCtx) }() - authURL := buildAuthURL(p.clientID, redirectURI, p.TargetCorpID) + authURL := buildAuthURLForRegion(p.clientID, redirectURI, p.TargetCorpID, p.LoginRegion) if p.logger != nil { p.logger.Debug("authorization URL", "url", authURL) } diff --git a/internal/auth/portable_store.go b/internal/auth/portable_store.go index 3be3f85a..420c9634 100644 --- a/internal/auth/portable_store.go +++ b/internal/auth/portable_store.go @@ -272,7 +272,7 @@ func ImportPortableAuthBundle(configDir string, r io.Reader) (PortableImportRepo func portableConfigFiles(configDir string) ([]string, error) { var files []string - patterns := []string{"app*.json", profilesJSONFile, "mcp_url", "terminal_url"} + patterns := []string{"app*.json", profilesJSONFile, "mcp_url", config.ManagedMCPURLRegionFileName, "terminal_url"} for _, pattern := range patterns { matches, err := portableGlob(filepath.Join(configDir, pattern)) if err != nil { diff --git a/internal/auth/token.go b/internal/auth/token.go index b4d02294..c511b45b 100644 --- a/internal/auth/token.go +++ b/internal/auth/token.go @@ -97,6 +97,7 @@ type TokenData struct { ClientID string `json:"client_id,omitempty"` // Associated app client ID for refresh UpdatedAt string `json:"updated_at,omitempty"` Source string `json:"source,omitempty"` + LoginRegion string `json:"login_region,omitempty"` // LegacyOrgScopedProfile is an in-memory destination for an explicitly // matched historical profile whose userId was never resolved. It is never // persisted as token material. diff --git a/internal/errors/errors_test.go b/internal/errors/errors_test.go index 092f2c84..60918b1b 100644 --- a/internal/errors/errors_test.go +++ b/internal/errors/errors_test.go @@ -15,9 +15,13 @@ package errors import ( stderrors "errors" + "os" + "path/filepath" "strings" "testing" "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" ) func TestCrossPlatformCoverageExitCodeByCategory(t *testing.T) { @@ -382,6 +386,27 @@ func TestCrossPlatformCoverageServerGuidanceSuppressesUnsafeActionURL(t *testing } } +func TestCrossPlatformCoveragePrintJSONCLIOrgNotAuthorizedUsesInternationalActionURL(t *testing.T) { + dir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", dir) + if err := os.WriteFile(filepath.Join(dir, "mcp_url"), []byte("https://mcp.dingtalk.io\n"), config.FilePerm); err != nil { + t.Fatalf("WriteFile(mcp_url) error = %v", err) + } + + var b strings.Builder + if err := PrintJSON(&b, NewAPI( + "business error", + WithServerDiag(ServerDiagnostics{ServerErrorCode: "CLI_ORG_NOT_AUTHORIZED"}), + )); err != nil { + t.Fatalf("PrintJSON() error = %v", err) + } + + want := `"action_url": "https://open-dev.dingtalk.io/fe/old#/developerSettings"` + if got := b.String(); !strings.Contains(got, want) { + t.Fatalf("expected international action_url %q, got %q", want, got) + } +} + func TestCrossPlatformCoveragePrintJSONIncludesRPCCodeAndData(t *testing.T) { t.Parallel() diff --git a/internal/transport/client.go b/internal/transport/client.go index ddbae10d..73bac675 100644 --- a/internal/transport/client.go +++ b/internal/transport/client.go @@ -50,14 +50,14 @@ func init() { Name: "DWS_TRUSTED_DOMAINS", Category: configmeta.CategoryNetwork, Description: "信任的 HTTPS 域名白名单 (逗号分隔,* 信任所有)", - DefaultValue: "*.dingtalk.com", - Example: "*.dingtalk.com,custom.example.com", + DefaultValue: "*.dingtalk.com,*.dingtalk.io", + Example: "*.dingtalk.com,*.dingtalk.io,custom.example.com", }) } const ( trustedDomainsEnv = "DWS_TRUSTED_DOMAINS" - defaultTrustedDomains = "*.dingtalk.com" + defaultTrustedDomains = "*.dingtalk.com,*.dingtalk.io" // defaultHTTPTimeout is the default timeout for HTTP transport requests. defaultHTTPTimeout = 30 * time.Second @@ -654,7 +654,7 @@ func shouldPreserveEndpointQuery(parsed *url.URL) bool { return false } switch strings.ToLower(parsed.Hostname()) { - case "mcp-gw.dingtalk.com", "pre-mcp-gw.dingtalk.com": + case "mcp-gw.dingtalk.com", "pre-mcp-gw.dingtalk.com", "mcp-gw.dingtalk.io", "pre-mcp-gw.dingtalk.io": return true default: return false diff --git a/internal/transport/client_extra_test.go b/internal/transport/client_extra_test.go index d9a1b070..782e0006 100644 --- a/internal/transport/client_extra_test.go +++ b/internal/transport/client_extra_test.go @@ -6,6 +6,7 @@ import ( "io" "log/slog" "net/http" + "net/url" "strings" "testing" ) @@ -234,6 +235,23 @@ func TestIsEndpointTrusted_DomainMatch(t *testing.T) { } } +func TestCrossPlatformCoverageIsEndpointTrustedDefaultIncludesDingTalkInternational(t *testing.T) { + t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "") + t.Setenv("DWS_TRUSTED_DOMAINS", "") + c := NewClient(nil) + if !c.isEndpointTrusted("https://mcp-gw.dingtalk.com/server/contact") { + t.Fatal("default trusted domains should trust dingtalk.com") + } + if !c.isEndpointTrusted("https://pre-mcp-gw.dingtalk.io/server/contact") { + t.Fatal("default trusted domains should trust dingtalk.io") + } + for _, host := range []string{"mcp-gw.dingtalk.io", "pre-mcp-gw.dingtalk.io"} { + if !shouldPreserveEndpointQuery(&url.URL{Scheme: "https", Host: host}) { + t.Fatalf("international gateway %q should preserve endpoint query", host) + } + } +} + func TestHttpStatusError(t *testing.T) { t.Parallel() codes := []int{ diff --git a/internal/transport/client_test.go b/internal/transport/client_test.go index 509a9a31..4d72256f 100644 --- a/internal/transport/client_test.go +++ b/internal/transport/client_test.go @@ -562,6 +562,16 @@ func TestSanitizeJSONRPCEndpointPreservesDingTalkMCPGatewayQuery(t *testing.T) { endpoint: "https://mcp-gw.dingtalk.com/server/demo?key=secret#frag", want: "https://mcp-gw.dingtalk.com/server/demo?key=secret", }, + { + name: "prepub international gateway", + endpoint: "https://pre-mcp-gw.dingtalk.io/server/demo?key=secret#frag", + want: "https://pre-mcp-gw.dingtalk.io/server/demo?key=secret", + }, + { + name: "prod international gateway", + endpoint: "https://mcp-gw.dingtalk.io/server/demo?key=secret#frag", + want: "https://mcp-gw.dingtalk.io/server/demo?key=secret", + }, } for _, tt := range cases { diff --git a/pkg/config/constants.go b/pkg/config/constants.go index c986e45a..03b682cf 100644 --- a/pkg/config/constants.go +++ b/pkg/config/constants.go @@ -17,6 +17,7 @@ package config import ( + "net/url" "os" "path/filepath" "strings" @@ -165,10 +166,19 @@ const ( // Override at runtime via ~/.dws/mcp_url file. DefaultMCPBaseURL = "https://mcp.dingtalk.com" + // ManagedMCPURLRegionFileName records an MCP URL written automatically by + // login region selection, so a later region change does not delete a user's + // explicit MCP override. + ManagedMCPURLRegionFileName = "mcp_url.login_region" + // DefaultTerminalBaseURL is the DingTalk developer platform base URL. // Override at runtime via ~/.dws/terminal_url file. DefaultTerminalBaseURL = "https://open-dev.dingtalk.com" + // InternationalTerminalBaseURL is the DingTalk international developer + // platform base URL. + InternationalTerminalBaseURL = "https://open-dev.dingtalk.io" + // DeveloperSettingsPath is the path to the organization developer // settings page (CLI access management). DeveloperSettingsPath = "/fe/old#/developerSettings" @@ -201,8 +211,9 @@ func GetMCPBaseURL() string { } // GetTerminalBaseURL returns the terminal base URL with priority: -// 1. ~/.dws/terminal_url file content (for pre-release environment) -// 2. Default value (https://open-dev.dingtalk.com) +// 1. ~/.dws/terminal_url file content (for custom environments) +// 2. International terminal when the configured MCP endpoint uses .io +// 3. Default value (https://open-dev.dingtalk.com) func GetTerminalBaseURL() string { terminalURLPath := filepath.Join(DefaultConfigDir(), "terminal_url") if data, err := os.ReadFile(terminalURLPath); err == nil { @@ -210,6 +221,12 @@ func GetTerminalBaseURL() string { return u } } + if parsed, err := url.Parse(GetMCPBaseURL()); err == nil { + host := strings.ToLower(parsed.Hostname()) + if host == "dingtalk.io" || strings.HasSuffix(host, ".dingtalk.io") { + return InternationalTerminalBaseURL + } + } return DefaultTerminalBaseURL } diff --git a/pkg/config/constants_test.go b/pkg/config/constants_test.go index c9e538b0..7296b231 100644 --- a/pkg/config/constants_test.go +++ b/pkg/config/constants_test.go @@ -219,6 +219,34 @@ func TestGetMCPBaseURLUsesConfigFile(t *testing.T) { } } +func TestCrossPlatformCoverageGetDeveloperSettingsURLUsesInternationalMCPRegion(t *testing.T) { + dir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", dir) + if err := os.WriteFile(filepath.Join(dir, "mcp_url"), []byte("https://mcp.dingtalk.io\n"), FilePerm); err != nil { + t.Fatalf("WriteFile(mcp_url) error = %v", err) + } + + want := "https://open-dev.dingtalk.io/fe/old#/developerSettings" + if got := GetDeveloperSettingsURL(); got != want { + t.Fatalf("GetDeveloperSettingsURL() = %q, want %q", got, want) + } +} + +func TestCrossPlatformCoverageGetTerminalBaseURLPrefersExplicitConfig(t *testing.T) { + dir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", dir) + if err := os.WriteFile(filepath.Join(dir, "mcp_url"), []byte("https://mcp.dingtalk.io\n"), FilePerm); err != nil { + t.Fatalf("WriteFile(mcp_url) error = %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "terminal_url"), []byte("https://custom-open-dev.example.com\n"), FilePerm); err != nil { + t.Fatalf("WriteFile(terminal_url) error = %v", err) + } + + if got := GetTerminalBaseURL(); got != "https://custom-open-dev.example.com" { + t.Fatalf("GetTerminalBaseURL() = %q, want explicit terminal URL", got) + } +} + func TestMaxUploadFileSize(t *testing.T) { t.Parallel() var want int64 = 100 * 1024 * 1024