diff --git a/cmd/config/bind_test.go b/cmd/config/bind_test.go index f154bfb3f..04ea8442a 100644 --- a/cmd/config/bind_test.go +++ b/cmd/config/bind_test.go @@ -916,25 +916,6 @@ func TestReadDotenv_ValueWithEquals(t *testing.T) { } } -func TestNormalizeBrand(t *testing.T) { - tests := []struct { - input string - want string - }{ - {"", "feishu"}, - {"feishu", "feishu"}, - {"lark", "lark"}, - {"LARK", "lark"}, - {" lark ", "lark"}, - {"Lark", "lark"}, - } - for _, tt := range tests { - if got := normalizeBrand(tt.input); got != tt.want { - t.Errorf("normalizeBrand(%q) = %q, want %q", tt.input, got, tt.want) - } - } -} - func TestResolveOpenClawConfigPath_Overrides(t *testing.T) { t.Run("OPENCLAW_CONFIG_PATH wins", func(t *testing.T) { custom := filepath.Join(t.TempDir(), "custom.json") diff --git a/cmd/config/binder.go b/cmd/config/binder.go index 6b04086df..a24f927be 100644 --- a/cmd/config/binder.go +++ b/cmd/config/binder.go @@ -205,7 +205,7 @@ func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) { return &core.AppConfig{ AppId: selected.AppID, AppSecret: stored, - Brand: core.LarkBrand(normalizeBrand(selected.Brand)), + Brand: core.ParseBrand(selected.Brand), }, nil } @@ -261,7 +261,7 @@ func (b *hermesBinder) Build(appID string) (*core.AppConfig, error) { return &core.AppConfig{ AppId: appID, AppSecret: stored, - Brand: core.LarkBrand(normalizeBrand(b.envMap["FEISHU_DOMAIN"])), + Brand: core.ParseBrand(b.envMap["FEISHU_DOMAIN"]), }, nil } @@ -326,7 +326,7 @@ func (b *larkChannelBinder) Build(appID string) (*core.AppConfig, error) { return &core.AppConfig{ AppId: appID, AppSecret: stored, - Brand: core.LarkBrand(normalizeBrand(b.cfg.Accounts.App.Tenant)), + Brand: core.ParseBrand(b.cfg.Accounts.App.Tenant), }, nil } @@ -350,16 +350,6 @@ func sourceDisplayName(source string) string { } } -// normalizeBrand applies .strip().lower() and defaults to "feishu". -// Aligns with Hermes gateway/platforms/feishu.py:1119 behavior. -func normalizeBrand(raw string) string { - s := strings.TrimSpace(strings.ToLower(raw)) - if s == "" { - return "feishu" - } - return s -} - // resolveHermesEnvPath returns the path to Hermes's .env file. // Respects HERMES_HOME override; defaults to ~/.hermes/.env. // diff --git a/cmd/config/init_interactive.go b/cmd/config/init_interactive.go index 0f17000a8..9af829d3c 100644 --- a/cmd/config/init_interactive.go +++ b/cmd/config/init_interactive.go @@ -146,6 +146,20 @@ func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, er }, nil } +// retryBrand decides whether app-registration polling must be retried on a +// different brand. When the secret was not issued and the tenant's real brand +// differs from the brand we polled, it returns the tenant's actual brand and true. +func retryBrand(polled core.LarkBrand, tenantBrand string) (core.LarkBrand, bool) { + if tenantBrand == "" { + return polled, false + } + actual := core.ParseBrand(tenantBrand) + if actual == polled { + return polled, false + } + return actual, true +} + // runCreateAppFlow runs the "create new app" flow via OpenClaw device flow. // If brandOverride is non-empty, skip the interactive brand selection. func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride core.LarkBrand, msg *initMsg) (*configInitResult, error) { @@ -208,18 +222,19 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", verificationURL) fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.WaitingForScanNonTTY) } - result, err := larkauth.PollAppRegistration(ctx, httpClient, core.BrandFeishu, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut) + result, err := larkauth.PollAppRegistration(ctx, httpClient, larkBrand, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut) if err != nil { return nil, errs.NewAuthenticationError(errs.SubtypeUnknown, "%v", err).WithCause(err) } - // Step 4: Handle Lark brand special case - // If tenant_brand=lark and no client_secret, retry with lark brand endpoint - if result.ClientSecret == "" && result.UserInfo != nil && result.UserInfo.TenantBrand == "lark" { - // fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.DetectedLarkTenant) - result, err = larkauth.PollAppRegistration(ctx, httpClient, core.BrandLark, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut) - if err != nil { - return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "lark endpoint retry failed: %v", err).WithCause(err) + // Step 4: Retry on the tenant's actual brand if the secret was not issued on + // the polled brand (e.g. configured feishu but the tenant is a lark tenant). + if result.ClientSecret == "" && result.UserInfo != nil { + if rb, ok := retryBrand(larkBrand, result.UserInfo.TenantBrand); ok { + result, err = larkauth.PollAppRegistration(ctx, httpClient, rb, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut) + if err != nil { + return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "brand endpoint retry failed: %v", err).WithCause(err) + } } } @@ -227,12 +242,12 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing client_id or client_secret") } - // Determine final brand from response + // Determine the final brand from the tenant's reported brand, normalized the + // same way as the retry decision (core.ParseBrand) so the saved brand always + // matches the endpoint the credentials were issued on. finalBrand := larkBrand - if result.UserInfo != nil && result.UserInfo.TenantBrand == "lark" { - finalBrand = core.BrandLark - } else if result.UserInfo != nil && result.UserInfo.TenantBrand == "feishu" { - finalBrand = core.BrandFeishu + if result.UserInfo != nil && result.UserInfo.TenantBrand != "" { + finalBrand = core.ParseBrand(result.UserInfo.TenantBrand) } fmt.Fprintln(f.IOStreams.ErrOut) diff --git a/cmd/config/init_interactive_test.go b/cmd/config/init_interactive_test.go new file mode 100644 index 000000000..8d937a037 --- /dev/null +++ b/cmd/config/init_interactive_test.go @@ -0,0 +1,31 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "testing" + + "github.com/larksuite/cli/internal/core" +) + +func TestRetryBrand(t *testing.T) { + cases := []struct { + polled core.LarkBrand + tenant string + wantBrand core.LarkBrand + wantRetry bool + }{ + {core.BrandFeishu, "lark", core.BrandLark, true}, + {core.BrandFeishu, "feishu", core.BrandFeishu, false}, + {core.BrandFeishu, "", core.BrandFeishu, false}, + {core.BrandLark, "feishu", core.BrandFeishu, true}, + {core.BrandLark, "lark", core.BrandLark, false}, + } + for _, c := range cases { + gotB, gotR := retryBrand(c.polled, c.tenant) + if gotR != c.wantRetry || gotB != c.wantBrand { + t.Errorf("retryBrand(%q,%q) = (%q,%v), want (%q,%v)", c.polled, c.tenant, gotB, gotR, c.wantBrand, c.wantRetry) + } + } +} diff --git a/cmd/update/update.go b/cmd/update/update.go index d42f87e9b..29aa181c7 100644 --- a/cmd/update/update.go +++ b/cmd/update/update.go @@ -13,6 +13,7 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/build" "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/selfupdate" "github.com/larksuite/cli/internal/skillscheck" @@ -125,6 +126,10 @@ func updateRun(opts *UpdateOptions) error { io := opts.Factory.IOStreams cur := currentVersion() updater := newUpdater() + updater.Brand = core.BrandFeishu + if cfg, err := opts.Factory.Config(); err == nil && cfg != nil { + updater.Brand = cfg.Brand + } if !opts.Check { updater.CleanupStaleFiles() diff --git a/extension/credential/env/env.go b/extension/credential/env/env.go index 054d27d85..545812568 100644 --- a/extension/credential/env/env.go +++ b/extension/credential/env/env.go @@ -9,6 +9,7 @@ import ( "os" "github.com/larksuite/cli/extension/credential" + "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/envvars" ) @@ -41,10 +42,7 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err Reason: envvars.CliAppID + " is set but no app secret or access token is available", } } - brand := credential.Brand(os.Getenv(envvars.CliBrand)) - if brand == "" { - brand = credential.BrandFeishu - } + brand := credential.Brand(core.ParseBrand(os.Getenv(envvars.CliBrand))) acct := &credential.Account{AppID: appID, AppSecret: appSecret, Brand: brand} switch id := credential.Identity(os.Getenv(envvars.CliDefaultAs)); id { diff --git a/extension/credential/sidecar/provider.go b/extension/credential/sidecar/provider.go index 99948939a..d01f56616 100644 --- a/extension/credential/sidecar/provider.go +++ b/extension/credential/sidecar/provider.go @@ -16,6 +16,7 @@ import ( "os" "github.com/larksuite/cli/extension/credential" + "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/envvars" "github.com/larksuite/cli/sidecar" ) @@ -58,10 +59,7 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err } } - brand := credential.Brand(os.Getenv(envvars.CliBrand)) - if brand == "" { - brand = credential.BrandFeishu - } + brand := credential.Brand(core.ParseBrand(os.Getenv(envvars.CliBrand))) acct := &credential.Account{ AppID: appID, diff --git a/internal/auth/app_registration.go b/internal/auth/app_registration.go index 2bdf50fba..e3a2204cc 100644 --- a/internal/auth/app_registration.go +++ b/internal/auth/app_registration.go @@ -39,6 +39,13 @@ type AppRegUserInfo struct { TenantBrand string // "feishu" or "lark" } +// appRegistrationEndpoint returns the accounts-domain registration endpoint for +// the given brand. Both the begin and poll actions target the same brand so the +// device_code issuer and poller domains always match. +func appRegistrationEndpoint(brand core.LarkBrand) string { + return core.ResolveEndpoints(brand).Accounts + PathAppRegistration +} + // RequestAppRegistration initiates the app registration device flow. func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, errOut io.Writer) (*AppRegistrationResponse, error) { if errOut == nil { @@ -46,8 +53,7 @@ func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, errOu } ep := core.ResolveEndpoints(brand) - regEp := core.ResolveEndpoints(core.BrandFeishu) // registration begin always uses feishu - endpoint := regEp.Accounts + PathAppRegistration + endpoint := appRegistrationEndpoint(brand) form := url.Values{} form.Set("action", "begin") @@ -129,8 +135,7 @@ func PollAppRegistration(ctx context.Context, httpClient *http.Client, brand cor const maxPollInterval = 60 const maxPollAttempts = 200 - ep := core.ResolveEndpoints(brand) - endpoint := ep.Accounts + PathAppRegistration + endpoint := appRegistrationEndpoint(brand) deadline := time.Now().Add(time.Duration(expiresIn) * time.Second) currentInterval := interval attempts := 0 diff --git a/internal/auth/app_registration_test.go b/internal/auth/app_registration_test.go index 34466a48f..a317d4079 100644 --- a/internal/auth/app_registration_test.go +++ b/internal/auth/app_registration_test.go @@ -6,6 +6,7 @@ package auth import ( "testing" + "github.com/larksuite/cli/internal/core" "github.com/smartystreets/goconvey/convey" ) @@ -31,3 +32,18 @@ func Test_BuildVerificationURL(t *testing.T) { }) }) } + +func TestAppRegistrationEndpoint(t *testing.T) { + cases := []struct { + brand core.LarkBrand + want string + }{ + {core.BrandFeishu, "https://accounts.feishu.cn" + PathAppRegistration}, + {core.BrandLark, "https://accounts.larksuite.com" + PathAppRegistration}, + } + for _, c := range cases { + if got := appRegistrationEndpoint(c.brand); got != c.want { + t.Errorf("brand %q: endpoint = %q, want %q", c.brand, got, c.want) + } + } +} diff --git a/internal/core/types.go b/internal/core/types.go index 26236df73..d1c07cef6 100644 --- a/internal/core/types.go +++ b/internal/core/types.go @@ -3,9 +3,11 @@ package core +import "strings" + // LarkBrand represents the Lark platform brand. // "feishu" targets China-mainland, "lark" targets international. -// Any other string is treated as a custom base URL. +// Any unrecognized value is normalized to BrandFeishu (the default brand). type LarkBrand string const ( @@ -14,9 +16,10 @@ const ( ) // ParseBrand normalizes a brand string to a LarkBrand constant. -// Unrecognized values default to BrandFeishu. +// Matching is case-insensitive and whitespace-tolerant; any value other than +// "lark" (after trim + lowercase) normalizes to BrandFeishu. func ParseBrand(value string) LarkBrand { - if value == "lark" { + if strings.ToLower(strings.TrimSpace(value)) == "lark" { return BrandLark } return BrandFeishu diff --git a/internal/core/types_test.go b/internal/core/types_test.go index 55cdd1f11..c20b0fb6d 100644 --- a/internal/core/types_test.go +++ b/internal/core/types_test.go @@ -57,3 +57,23 @@ func TestResolveOpenBaseURL(t *testing.T) { t.Errorf("ResolveOpenBaseURL(lark) = %q", got) } } + +func TestParseBrand(t *testing.T) { + cases := []struct { + in string + want LarkBrand + }{ + {"", BrandFeishu}, + {"feishu", BrandFeishu}, + {"lark", BrandLark}, + {"LARK", BrandLark}, + {" lark ", BrandLark}, + {"Lark", BrandLark}, + {"xyz", BrandFeishu}, + } + for _, c := range cases { + if got := ParseBrand(c.in); got != c.want { + t.Errorf("ParseBrand(%q) = %q, want %q", c.in, got, c.want) + } + } +} diff --git a/internal/registry/remote.go b/internal/registry/remote.go index 7e83003a4..6f47441dc 100644 --- a/internal/registry/remote.go +++ b/internal/registry/remote.go @@ -75,13 +75,7 @@ func remoteMetaURL(version string) string { if testMetaURL != "" { return testMetaURL } - var base string - switch configuredBrand { - case core.BrandLark: - base = "https://open.larksuite.com/api/tools/open/api_definition" - default: - base = "https://open.feishu.cn/api/tools/open/api_definition" - } + base := core.ResolveEndpoints(configuredBrand).Open + "/api/tools/open/api_definition" q := "protocol=meta&client_version=" + url.QueryEscape(build.Version) if version != "" { q += "&data_version=" + url.QueryEscape(version) diff --git a/internal/selfupdate/updater.go b/internal/selfupdate/updater.go index 4b8f93a18..c3997490e 100644 --- a/internal/selfupdate/updater.go +++ b/internal/selfupdate/updater.go @@ -16,6 +16,7 @@ import ( "strings" "time" + "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/transport" "github.com/larksuite/cli/internal/vfs" ) @@ -49,7 +50,10 @@ const ( var ( skillsIndexFetchTimeout = 10 * time.Second - officialSkillsIndexURL = "https://open.feishu.cn/.well-known/skills/index.json" + // officialSkillsIndexURL, when non-empty, overrides the brand-derived skills + // index URL. It is empty in production (the URL is resolved from the brand + // via core.ResolveEndpoints) and set by tests to a local server. + officialSkillsIndexURL = "" ) // DetectResult holds installation detection results. @@ -101,6 +105,10 @@ func (r *NpmResult) CombinedOutput() string { // Override DetectOverride / NpmInstallOverride / SkillsCommandOverride / VerifyOverride // / RestoreAvailableOverride for testing. type Updater struct { + // Brand selects the endpoint set for skills index/source resolution. + // Zero value resolves to the feishu default via core.ResolveEndpoints. + Brand core.LarkBrand + DetectOverride func() DetectResult NpmInstallOverride func(version string) *NpmResult PnpmInstallOverride func(version string) *NpmResult @@ -129,6 +137,21 @@ type Updater struct { // New creates an Updater with default (real) behavior. func New() *Updater { return &Updater{} } +// skillsIndexURL returns the well-known skills index URL for the Updater's +// brand. A non-empty officialSkillsIndexURL (tests only) takes precedence. +func (u *Updater) skillsIndexURL() string { + if officialSkillsIndexURL != "" { + return officialSkillsIndexURL + } + return core.ResolveEndpoints(u.Brand).Open + "/.well-known/skills/index.json" +} + +// skillsSource returns the skills repository source host for the Updater's +// brand, used as the `npx skills add ` argument. +func (u *Updater) skillsSource() string { + return core.ResolveEndpoints(u.Brand).Open +} + // DetectInstallMethod determines how the CLI was installed and whether the // owning package manager is available for auto-update. func (u *Updater) DetectInstallMethod() DetectResult { @@ -258,7 +281,7 @@ func (u *Updater) ListOfficialSkillsIndex() *NpmResult { ctx, cancel := context.WithTimeout(context.Background(), skillsIndexFetchTimeout) defer cancel() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, officialSkillsIndexURL, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.skillsIndexURL(), nil) if err != nil { r.Err = err return r @@ -297,7 +320,7 @@ func (u *Updater) ListOfficialSkillsIndex() *NpmResult { } func (u *Updater) ListOfficialSkills() *NpmResult { - r := u.runSkillsListOfficial("https://open.feishu.cn") + r := u.runSkillsListOfficial(u.skillsSource()) if r.Err != nil { r = u.runSkillsListOfficial("larksuite/cli") } @@ -313,7 +336,7 @@ func (u *Updater) ListGlobalSkillsJSON() *NpmResult { } func (u *Updater) InstallSkill(nameList []string) *NpmResult { - r := u.runSkillsInstall("https://open.feishu.cn", nameList) + r := u.runSkillsInstall(u.skillsSource(), nameList) if r.Err != nil { r = u.runSkillsInstall("larksuite/cli", nameList) } @@ -321,7 +344,7 @@ func (u *Updater) InstallSkill(nameList []string) *NpmResult { } func (u *Updater) InstallAllSkills() *NpmResult { - r := u.runSkillsAdd("https://open.feishu.cn") + r := u.runSkillsAdd(u.skillsSource()) if r.Err != nil { r = u.runSkillsAdd("larksuite/cli") } diff --git a/internal/selfupdate/updater_test.go b/internal/selfupdate/updater_test.go index 93176265e..967f8cbd7 100644 --- a/internal/selfupdate/updater_test.go +++ b/internal/selfupdate/updater_test.go @@ -17,6 +17,7 @@ import ( "testing" "time" + "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/vfs" ) @@ -515,3 +516,23 @@ func TestDetectInstallMethod_Caches(t *testing.T) { t.Errorf("expected cached pnpm result to be returned, got %+v", got) } } + +func TestSkillsBrandHosts(t *testing.T) { + cases := []struct { + brand core.LarkBrand + wantIndex string + wantSource string + }{ + {core.BrandFeishu, "https://open.feishu.cn/.well-known/skills/index.json", "https://open.feishu.cn"}, + {core.BrandLark, "https://open.larksuite.com/.well-known/skills/index.json", "https://open.larksuite.com"}, + } + for _, c := range cases { + u := &Updater{Brand: c.brand} + if got := u.skillsIndexURL(); got != c.wantIndex { + t.Errorf("brand %q: skillsIndexURL = %q, want %q", c.brand, got, c.wantIndex) + } + if got := u.skillsSource(); got != c.wantSource { + t.Errorf("brand %q: skillsSource = %q, want %q", c.brand, got, c.wantSource) + } + } +} diff --git a/internal/update/update.go b/internal/update/update.go index 7bdc61d9c..e6a4d1fc9 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "net/http" + "net/url" "os" "path/filepath" "regexp" @@ -23,7 +24,6 @@ import ( ) const ( - registryURL = "https://registry.npmjs.org/@larksuite/cli/latest" cacheTTL = 24 * time.Hour fetchTimeout = 15 * time.Second stateFile = "update-state.json" @@ -202,8 +202,25 @@ type npmLatestResponse struct { Version string `json:"version"` } +// resolveRegistryURL builds the npm registry metadata URL for @larksuite/cli. +// It honors npm_config_registry when set to a valid https registry (npm +// ecosystem convention), otherwise falls back to the public npmjs default. +// This request carries no Lark credentials — it is package-distribution only. +func resolveRegistryURL() string { + const pkgPath = "/@larksuite/cli/latest" + const defaultBase = "https://registry.npmjs.org" + raw := strings.TrimSpace(os.Getenv("npm_config_registry")) + if raw != "" { + if u, err := url.Parse(raw); err == nil && u.Scheme == "https" && u.Host != "" { + base := strings.TrimRight(u.Scheme+"://"+u.Host+u.Path, "/") + return base + pkgPath + } + } + return defaultBase + pkgPath +} + func fetchLatestVersion() (string, error) { - resp, err := httpClient().Get(registryURL) + resp, err := httpClient().Get(resolveRegistryURL()) if err != nil { return "", err } diff --git a/internal/update/update_test.go b/internal/update/update_test.go index d19619cf3..1098188b5 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -275,3 +275,23 @@ func TestIsCIEnv(t *testing.T) { }) } } + +func TestResolveRegistryURL(t *testing.T) { + cases := []struct { + name, env, want string + }{ + {"unset", "", "https://registry.npmjs.org/@larksuite/cli/latest"}, + {"custom https", "https://corp.example.com/repository/npm/", "https://corp.example.com/repository/npm/@larksuite/cli/latest"}, + {"trailing slashes", "https://corp.example.com///", "https://corp.example.com/@larksuite/cli/latest"}, + {"non-https ignored", "http://internal.example.com/", "https://registry.npmjs.org/@larksuite/cli/latest"}, + {"garbage ignored", "not a url", "https://registry.npmjs.org/@larksuite/cli/latest"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Setenv("npm_config_registry", c.env) + if got := resolveRegistryURL(); got != c.want { + t.Errorf("resolveRegistryURL() = %q, want %q", got, c.want) + } + }) + } +} diff --git a/lint/README.md b/lint/README.md index 1fe7ceae2..91e92597d 100644 --- a/lint/README.md +++ b/lint/README.md @@ -30,8 +30,21 @@ lint/ ├── rule_subtype_classifier.go ├── rule_typed_error_completeness.go └── *_test.go +└── domaincontract/ # endpoint domain contract: no hardcoded resolver hosts + ├── scan.go # ScanRepo(root) ([]lintapi.Violation, error) ← public entry + └── scan_test.go ``` +## Endpoint domain contract (`domaincontract`) + +Every outbound Lark domain must be resolved through `core.ResolveEndpoints`. +The `domaincontract` domain rejects string literals containing a resolver-owned +host FQDN (`{open,accounts,mcp,applink}.{feishu.cn,larksuite.com}`) in any +production `.go` file. The only allowlisted files are the resolver definition +(`internal/core/types.go`) and this rule's own host list +(`lint/domaincontract/scan.go`). Comments and `_test.go` files are not scanned. +To add or change an outbound endpoint, edit the resolver — never hardcode a host. + ## Running ```bash diff --git a/lint/domaincontract/scan.go b/lint/domaincontract/scan.go new file mode 100644 index 000000000..56aafc6a2 --- /dev/null +++ b/lint/domaincontract/scan.go @@ -0,0 +1,97 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package domaincontract guards against hardcoded resolver-owned host FQDNs. +// Every outbound Lark domain must be resolved via core.ResolveEndpoints; the +// only place these host strings may appear as literals is the resolver itself. +package domaincontract + +import ( + "go/ast" + "go/parser" + "go/token" + "io/fs" + "path/filepath" + "strings" + + "github.com/larksuite/cli/lint/lintapi" +) + +// forbiddenHosts are the resolver-owned FQDNs. They may only appear as string +// literals in the allowlisted resolver source. +var forbiddenHosts = []string{ + "open.feishu.cn", "accounts.feishu.cn", "mcp.feishu.cn", "applink.feishu.cn", + "open.larksuite.com", "accounts.larksuite.com", "mcp.larksuite.com", "applink.larksuite.com", +} + +// allowlist maps repo-relative paths permitted to contain the literals: the +// resolver definition itself, and this rule's own forbidden-host list. +var allowlist = map[string]bool{ + filepath.FromSlash("internal/core/types.go"): true, + filepath.FromSlash("lint/domaincontract/scan.go"): true, +} + +func skipDir(name string) bool { + switch name { + case "vendor", "testdata", "node_modules", ".git", ".claude": + return true + } + return false +} + +// ScanRepo walks production .go files under root and flags string literals +// containing a forbidden resolver host outside the allowlist. Comments and +// _test.go files are not scanned. +func ScanRepo(root string) ([]lintapi.Violation, error) { + var out []lintapi.Violation + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if skipDir(d.Name()) { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + rel, relErr := filepath.Rel(root, path) + if relErr == nil && allowlist[rel] { + return nil + } + fset := token.NewFileSet() + file, perr := parser.ParseFile(fset, path, nil, 0) + if perr != nil { + return nil // unparseable file: not our concern + } + display := path + if relErr == nil { + display = rel + } + ast.Inspect(file, func(n ast.Node) bool { + lit, ok := n.(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + return true + } + for _, host := range forbiddenHosts { + if strings.Contains(lit.Value, host) { + pos := fset.Position(lit.Pos()) + out = append(out, lintapi.Violation{ + Rule: "no-hardcoded-endpoint", + Action: lintapi.ActionReject, + File: display, + Line: pos.Line, + Message: "hardcoded resolver host " + host + " — outbound domains must come from core.ResolveEndpoints", + Suggestion: "use core.ResolveEndpoints(brand) instead of a literal host", + }) + return true + } + } + return true + }) + return nil + }) + return out, err +} diff --git a/lint/domaincontract/scan_test.go b/lint/domaincontract/scan_test.go new file mode 100644 index 000000000..cc4890fdd --- /dev/null +++ b/lint/domaincontract/scan_test.go @@ -0,0 +1,44 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package domaincontract + +import ( + "os" + "path/filepath" + "testing" +) + +func writeFile(t *testing.T, root, rel, content string) { + t.Helper() + p := filepath.Join(root, rel) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestScanRepo(t *testing.T) { + root := t.TempDir() + // Negative: allowlisted resolver source may hold the literals. + writeFile(t, root, "internal/core/types.go", "package core\n\nvar x = \"https://open.feishu.cn\"\n") + // Negative: non-resolver hosts + a comment reference must not trip the guard. + writeFile(t, root, "shortcuts/x/display.go", "package x\n\n// see https://open.feishu.cn/document/foo\nvar h = \"https://www.feishu.cn\"\nvar e = \"https://example.feishu.cn\"\nvar r = \"https://registry.npmjs.org/pkg\"\n") + // Negative: _test.go files may assert literals. + writeFile(t, root, "internal/y/y_test.go", "package y\n\nvar w = \"https://open.larksuite.com\"\n") + // Positive: production literal outside the allowlist. + writeFile(t, root, "internal/z/z.go", "package z\n\nvar bad = \"https://accounts.larksuite.com/oauth\"\n") + + vs, err := ScanRepo(root) + if err != nil { + t.Fatal(err) + } + if len(vs) != 1 { + t.Fatalf("got %d violations, want 1: %+v", len(vs), vs) + } + if filepath.Base(vs[0].File) != "z.go" { + t.Errorf("violation in %q, want z.go", vs[0].File) + } +} diff --git a/lint/main.go b/lint/main.go index 0fb5ddbae..3a96a407f 100644 --- a/lint/main.go +++ b/lint/main.go @@ -30,6 +30,7 @@ import ( "fmt" "os" + "github.com/larksuite/cli/lint/domaincontract" "github.com/larksuite/cli/lint/errscontract" "github.com/larksuite/cli/lint/lintapi" ) @@ -43,6 +44,9 @@ type scanner struct { var scanners = []scanner{ {name: "errscontract", fn: errscontract.ScanRepoWithOptions}, + {name: "domaincontract", fn: func(root string, _ errscontract.ScanOptions) ([]lintapi.Violation, error) { + return domaincontract.ScanRepo(root) + }}, } func main() { diff --git a/shortcuts/event/subscribe.go b/shortcuts/event/subscribe.go index 941f5a700..8a36da2b8 100644 --- a/shortcuts/event/subscribe.go +++ b/shortcuts/event/subscribe.go @@ -21,7 +21,6 @@ import ( "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" - lark "github.com/larksuite/oapi-sdk-go/v3" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" larkevent "github.com/larksuite/oapi-sdk-go/v3/event" "github.com/larksuite/oapi-sdk-go/v3/event/dispatcher" @@ -247,10 +246,7 @@ var EventSubscribe = common.Shortcut{ } // --- WebSocket --- - domain := lark.FeishuBaseUrl - if runtime.Config.Brand == core.BrandLark { - domain = lark.LarkBaseUrl - } + domain := core.ResolveEndpoints(runtime.Config.Brand).Open info(fmt.Sprintf("%sConnecting to Lark event WebSocket...%s", output.Cyan, output.Reset)) if eventTypeFilter != nil { diff --git a/shortcuts/event/subscribe_test.go b/shortcuts/event/subscribe_test.go new file mode 100644 index 000000000..5d0491fd7 --- /dev/null +++ b/shortcuts/event/subscribe_test.go @@ -0,0 +1,24 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import ( + "testing" + + "github.com/larksuite/cli/internal/core" + lark "github.com/larksuite/oapi-sdk-go/v3" +) + +// TestWSDomainMatchesResolver guards the invariant that lets the event +// WebSocket domain be resolved via core.ResolveEndpoints instead of the SDK +// base-URL constants: the resolver's Open host must equal the SDK's per-brand +// base URL. If the SDK constants ever drift from the resolver, this fails. +func TestWSDomainMatchesResolver(t *testing.T) { + if got, want := core.ResolveEndpoints(core.BrandFeishu).Open, lark.FeishuBaseUrl; got != want { + t.Errorf("feishu WS domain = %q, want SDK %q", got, want) + } + if got, want := core.ResolveEndpoints(core.BrandLark).Open, lark.LarkBaseUrl; got != want { + t.Errorf("lark WS domain = %q, want SDK %q", got, want) + } +}