From 759173240d77079bed4aa3495a7a92ba25142ee9 Mon Sep 17 00:00:00 2001 From: "guokexin.02" <264159873+Tantanz20020918@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:05:36 +0800 Subject: [PATCH 1/8] feat: support URL rewriting extensions --- cmd/build.go | 1 + cmd/event/console_url.go | 5 +- cmd/root_help.go | 15 +- cmd/root_test.go | 13 ++ cmd/update/update.go | 57 ++++-- cmd/update/update_test.go | 14 +- extension/README.md | 8 +- extension/transport/registry.go | 10 +- extension/transport/types.go | 24 ++- internal/errclass/classify.go | 5 +- .../config/allowlists/public-domains.txt | 2 + internal/registry/scope_hint.go | 5 +- internal/selfupdate/updater.go | 10 ++ internal/selfupdate/updater_test.go | 31 +++- internal/testutil/urlrewrite/urlrewrite.go | 37 ++++ internal/transport/extension.go | 74 +++++--- internal/transport/extension_test.go | 165 ++++++++++++++++++ internal/urlrewrite/rewrite.go | 54 ++++++ internal/urlrewrite/rewrite_test.go | 48 +++++ shortcuts/apps/apps_init.go | 14 +- shortcuts/apps/apps_init_test.go | 6 +- shortcuts/calendar/description_rich_images.go | 3 +- shortcuts/common/resource_url.go | 21 ++- shortcuts/doc/docs_fetch_im_markdown.go | 4 + shortcuts/doc/docs_fetch_im_markdown_test.go | 18 +- .../drive/drive_permission_get_setting.go | 3 +- .../drive_permission_get_setting_test.go | 3 +- shortcuts/im/chat_app_link.go | 3 +- shortcuts/im/chat_app_link_test.go | 3 +- shortcuts/im/convert_lib/content_convert.go | 5 +- shortcuts/im/im_chat_messages_list.go | 3 +- shortcuts/im/im_messages_mget.go | 3 +- shortcuts/im/im_threads_messages_list.go | 3 +- shortcuts/mail/large_attachment.go | 7 +- shortcuts/mail/large_attachment_test.go | 15 +- shortcuts/okr/okr_progress_create.go | 3 +- shortcuts/wiki/wiki_node_create_test.go | 3 +- 37 files changed, 608 insertions(+), 90 deletions(-) create mode 100644 internal/testutil/urlrewrite/urlrewrite.go create mode 100644 internal/urlrewrite/rewrite.go create mode 100644 internal/urlrewrite/rewrite_test.go diff --git a/cmd/build.go b/cmd/build.go index 9b30053f05..85d3715517 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -374,6 +374,7 @@ func buildInternalWithConfig(ctx context.Context, inv cmdutil.InvocationContext, // mechanically unchanged. var hasConcealedCommands bool runtime.surface, hasConcealedCommands = applyDistributionPresentation(rootCmd, cfg.presentation, denied) + rootCmd.SetUsageTemplate(rewrittenRootUsageTemplate(runtime.surface)) // Resolve skill assets and canonical references before installing hooks. // A declared customization is a build-integrity boundary: failure must diff --git a/cmd/event/console_url.go b/cmd/event/console_url.go index efe95597c7..45b331a409 100644 --- a/cmd/event/console_url.go +++ b/cmd/event/console_url.go @@ -12,6 +12,7 @@ import ( "github.com/larksuite/cli/internal/core" eventlib "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/urlrewrite" ) // Landing-page contract for the scan-to-enable deep link, verified against the @@ -73,13 +74,13 @@ func consoleAddonsURL(brand core.LarkBrand, appID string, a ManifestAddons) (str return "", err } host := core.ResolveEndpoints(brand).Open - return fmt.Sprintf("%s%s?%s=%s&addons=%s", host, addonsLandingPath, addonsClientIDParam, appID, encoded), nil + return urlrewrite.Rewrite(fmt.Sprintf("%s%s?%s=%s&addons=%s", host, addonsLandingPath, addonsClientIDParam, appID, encoded)), nil } // consoleLandingURL is the bare landing page (no addons) — fallback when encoding fails. func consoleLandingURL(brand core.LarkBrand, appID string) string { host := core.ResolveEndpoints(brand).Open - return fmt.Sprintf("%s%s?%s=%s", host, addonsLandingPath, addonsClientIDParam, appID) + return urlrewrite.Rewrite(fmt.Sprintf("%s%s?%s=%s", host, addonsLandingPath, addonsClientIDParam, appID)) } // addonsHintURL returns the scan URL, degrading to the bare landing page on encode error. diff --git a/cmd/root_help.go b/cmd/root_help.go index a0446f6f9e..f426d1c7ae 100644 --- a/cmd/root_help.go +++ b/cmd/root_help.go @@ -4,9 +4,11 @@ package cmd import ( + "fmt" "strings" "github.com/larksuite/cli/internal/surface" + "github.com/larksuite/cli/internal/urlrewrite" ) // rootHelpFragment is one framework-owned root-help fragment. A fragment with @@ -142,13 +144,24 @@ Skills setup (one-time, humans): npx skills add larksuite/cli -g -y — https:// var rootUsageTemplate = renderRootUsageTemplate(nil) func renderRootUsageTemplate(plan *surface.Plan) string { + return renderRootUsageTemplateWithSkillsURL(plan, "https://github.com/larksuite/cli#agent-skills") +} + +func renderRootUsageTemplateWithSkillsURL(plan *surface.Plan, skillsURL string) string { var b strings.Builder b.WriteString(rootUsageTemplatePrefix) b.WriteString(renderRootHelpFragments(rootUsageSynopsis, plan)) b.WriteString(rootUsageTemplateSuffix) if plan.CanReference(surface.CommandSkillsRead) { - b.WriteString(skillsSetupFooter) + b.WriteString(fmt.Sprintf(`{{if not .HasParent}} + +Skills setup (one-time, humans): npx skills add larksuite/cli -g -y — %s{{end}}`, skillsURL)) } b.WriteByte('\n') return b.String() } + +func rewrittenRootUsageTemplate(plan *surface.Plan) string { + return renderRootUsageTemplateWithSkillsURL(plan, + urlrewrite.Rewrite("https://github.com/larksuite/cli#agent-skills")) +} diff --git a/cmd/root_test.go b/cmd/root_test.go index d952fed082..9d3c72b771 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -5,6 +5,7 @@ package cmd import ( "bytes" + "context" "encoding/json" "errors" "fmt" @@ -28,6 +29,7 @@ import ( "github.com/larksuite/cli/internal/recovery" "github.com/larksuite/cli/internal/registry" "github.com/larksuite/cli/internal/surface" + testurlrewrite "github.com/larksuite/cli/internal/testutil/urlrewrite" ) // TestPersistentPreRunE_AuthCheckDisabledAnnotations verifies that @@ -90,6 +92,17 @@ func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) { } } +func TestBuildRewritesRootSkillsHelpURLAfterProviderRegistration(t *testing.T) { + testurlrewrite.Register(t, func(rawURL string) string { + return strings.Replace(rawURL, "github.com", "mirror.example.test", 1) + }) + + _, root, _ := buildInternal(context.Background(), buildInvocationForTest(t), WithoutPlugins()) + if got := root.UsageTemplate(); !strings.Contains(got, "https://mirror.example.test/larksuite/cli#agent-skills") { + t.Fatalf("root help URL was not rewritten:\n%s", got) + } +} + func TestConfigureFlagCompletions(t *testing.T) { t.Cleanup(func() { cmdutil.SetFlagCompletionsEnabled(false) }) diff --git a/cmd/update/update.go b/cmd/update/update.go index 8f5d7c44ed..1f5e40018d 100644 --- a/cmd/update/update.go +++ b/cmd/update/update.go @@ -4,6 +4,7 @@ package cmdupdate import ( + "context" "fmt" stdio "io" "runtime" @@ -19,6 +20,7 @@ import ( "github.com/larksuite/cli/internal/selfupdate" "github.com/larksuite/cli/internal/skillscheck" "github.com/larksuite/cli/internal/update" + "github.com/larksuite/cli/internal/urlrewrite" ) const ( @@ -114,7 +116,7 @@ Use --check to only check for updates without installing. The skill name "lark-suite" is reserved for CLI-managed suite layout.`, RunE: func(cmd *cobra.Command, args []string) error { - return updateRun(opts) + return updateRunWithContext(cmd.Context(), opts) }, } cmdutil.DisableAuthCheck(cmd) @@ -128,6 +130,10 @@ The skill name "lark-suite" is reserved for CLI-managed suite layout.`, } func updateRun(opts *UpdateOptions) error { + return updateRunWithContext(nil, opts) +} + +func updateRunWithContext(ctx context.Context, opts *UpdateOptions) error { io := opts.Factory.IOStreams if _, err := skillscheck.ParseLayout(opts.SkillsLayout); err != nil { return reportError(opts, io, "validation", @@ -189,6 +195,18 @@ func updateRun(opts *UpdateOptions) error { return doAutoUpdate(opts, io, cur, latest, detect, updater) } +type presentationURLs struct { + release string + changelog string +} + +func resolvePresentationURLs(latest string) presentationURLs { + return presentationURLs{ + release: urlrewrite.Rewrite(releaseURL(latest)), + changelog: urlrewrite.Rewrite(changelogURL()), + } +} + // resolveSkillsBrand returns the skills-source brand: resolved config first, // then the active profile's raw config entry (the brand is not a secret; a // locked keychain must not flip the source), then the default with a notice. @@ -230,21 +248,22 @@ func reportErrorWithFields(opts *UpdateOptions, io *cmdutil.IOStreams, errType s } func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, canAutoUpdate bool) error { + urls := resolvePresentationURLs(latest) if opts.JSON { out := map[string]interface{}{ "ok": true, "previous_version": cur, "current_version": cur, "latest_version": latest, "action": "update_available", "auto_update": canAutoUpdate, "message": fmt.Sprintf("lark-cli %s %s %s available", cur, symArrow(), latest), - "url": releaseURL(latest), "changelog": changelogURL(), + "url": urls.release, "changelog": urls.changelog, } applySkillsStatus(out, cur) output.PrintJson(io.Out, out) return nil } fmt.Fprintf(io.ErrOut, "Update available: %s %s %s\n", cur, symArrow(), latest) - fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest)) - fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL()) + fmt.Fprintf(io.ErrOut, " Release: %s\n", urls.release) + fmt.Fprintf(io.ErrOut, " Changelog: %s\n", urls.changelog) if canAutoUpdate { fmt.Fprintf(io.ErrOut, "\nRun `lark-cli update` to install.\n") } else { @@ -254,6 +273,7 @@ func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest s } func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error { + urls := resolvePresentationURLs(latest) skillsResult := runSkillsAndState(updater, io, cur, opts.Force, opts.SkillsLayout) reason := detect.ManualReason() if opts.JSON { @@ -261,7 +281,7 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri "ok": true, "previous_version": cur, "latest_version": latest, "action": "manual_required", "message": fmt.Sprintf("Automatic update unavailable: %s (path: %s)", reason, detect.ResolvedPath), - "url": releaseURL(latest), "changelog": changelogURL(), + "url": urls.release, "changelog": urls.changelog, } applySkillsResult(out, skillsResult) if err := reportSkillsFailureWithFields(opts, io, skillsResult, out); err != nil { @@ -272,8 +292,8 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri } fmt.Fprintf(io.ErrOut, "Automatic update unavailable: %s (path: %s).\n\n", reason, detect.ResolvedPath) fmt.Fprintf(io.ErrOut, "To update manually, download the latest release:\n") - fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest)) - fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL()) + fmt.Fprintf(io.ErrOut, " Release: %s\n", urls.release) + fmt.Fprintf(io.ErrOut, " Changelog: %s\n", urls.changelog) if detect.Method == selfupdate.InstallPnpm { fmt.Fprintf(io.ErrOut, "\nOr install via pnpm (note: skills will not be synced):\n pnpm add -g %s@%s\n pnpm dlx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest) } else { @@ -287,6 +307,7 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri } func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error { + urls := resolvePresentationURLs(latest) pm := "npm" install := updater.RunNpmInstall if detect.Method == selfupdate.InstallPnpm { @@ -308,12 +329,13 @@ func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string if npmResult.Err != nil { restore() combined := npmResult.CombinedOutput() + hint := permissionHint(combined, pm) if opts.JSON { output.PrintJson(io.Out, map[string]interface{}{ "ok": false, "error": map[string]interface{}{ "type": "update_error", "message": fmt.Sprintf("%s install failed: %s", pm, npmResult.Err), "detail": selfupdate.Truncate(combined, maxNpmOutput), - "hint": permissionHint(combined, pm), + "hint": hint, }, }) return output.ErrBare(output.ExitAPI) @@ -325,7 +347,7 @@ func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string fmt.Fprint(io.ErrOut, npmResult.Stderr.String()) } fmt.Fprintf(io.ErrOut, "\n%s Update failed: %s\n", symFail(), npmResult.Err) - if hint := permissionHint(combined, pm); hint != "" { + if hint != "" { fmt.Fprintf(io.ErrOut, " %s\n", hint) } return output.ErrBare(output.ExitAPI) @@ -355,12 +377,12 @@ func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string "previous_version": cur, "current_version": latest, "latest_version": latest, "action": "updated", "message": fmt.Sprintf("lark-cli updated from %s to %s, but skills update failed", cur, latest), - "url": releaseURL(latest), "changelog": changelogURL(), + "url": urls.release, "changelog": urls.changelog, } applySkillsResult(fields, skillsResult) if !opts.JSON { fmt.Fprintf(io.ErrOut, "\n%s lark-cli binary updated from %s to %s\n", symOK(), cur, latest) - fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL()) + fmt.Fprintf(io.ErrOut, " Changelog: %s\n", urls.changelog) } return reportSkillsFailureWithFields(opts, io, skillsResult, fields) } @@ -370,7 +392,7 @@ func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string "ok": true, "previous_version": cur, "current_version": latest, "latest_version": latest, "action": "updated", "message": fmt.Sprintf("lark-cli updated from %s to %s", cur, latest), - "url": releaseURL(latest), "changelog": changelogURL(), + "url": urls.release, "changelog": urls.changelog, } applySkillsResult(result, skillsResult) output.PrintJson(io.Out, result) @@ -378,7 +400,7 @@ func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string } fmt.Fprintf(io.ErrOut, "\n%s Successfully updated lark-cli from %s to %s\n", symOK(), cur, latest) - fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL()) + fmt.Fprintf(io.ErrOut, " Changelog: %s\n", urls.changelog) if skillsResult != nil { skillsPM := "npx" if detect.Method == selfupdate.InstallPnpm && detect.PnpmAvailable { @@ -395,19 +417,20 @@ func permissionHint(pmOutput, pm string) string { return "" } if pm == "pnpm" { - return "Permission denied. Ensure your pnpm global directory is writable — re-run `pnpm setup`, or see https://pnpm.io/pnpm-cli" + return "Permission denied. Ensure your pnpm global directory is writable — re-run `pnpm setup`, or see " + urlrewrite.Rewrite("https://pnpm.io/pnpm-cli") } - return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors" + return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: " + urlrewrite.Rewrite("https://docs.npmjs.com/resolving-eacces-permissions-errors") } func verificationFailureHint(updater *selfupdate.Updater, latest, pm string) string { if updater.CanRestorePreviousVersion() { return "the previous version has been restored" } + release := urlrewrite.Rewrite(releaseURL(latest)) if pm == "pnpm" { - return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): pnpm add -g %s@%s && pnpm dlx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest)) + return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): pnpm add -g %s@%s && pnpm dlx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, release) } - return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest)) + return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, release) } func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, stateVersion string, force bool, requestedLayout string) *skillscheck.SyncResult { diff --git a/cmd/update/update_test.go b/cmd/update/update_test.go index 68df818984..78bd51ee2a 100644 --- a/cmd/update/update_test.go +++ b/cmd/update/update_test.go @@ -22,6 +22,7 @@ import ( "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/selfupdate" "github.com/larksuite/cli/internal/skillscheck" + testurlrewrite "github.com/larksuite/cli/internal/testutil/urlrewrite" ) const runLiveSkillsTestsEnv = "LARKSUITE_CLI_RUN_LIVE_SKILLS_TESTS" @@ -907,6 +908,17 @@ func TestReleaseURL(t *testing.T) { } } +func TestResolvePresentationURLsRewrites(t *testing.T) { + testurlrewrite.Register(t, func(rawURL string) string { + return strings.Replace(rawURL, "github.com", "mirror.example.test", 1) + }) + + got := resolvePresentationURLs("2.0.0") + if got.release != "https://mirror.example.test/larksuite/cli/releases/tag/v2.0.0" || got.changelog != "https://mirror.example.test/larksuite/cli/blob/main/CHANGELOG.md" { + t.Fatalf("resolvePresentationURLs() = %#v", got) + } +} + func TestPermissionHint(t *testing.T) { origOS := currentOS defer func() { currentOS = origOS }() @@ -922,7 +934,7 @@ func TestPermissionHint(t *testing.T) { } // Linux + pnpm: EACCES should point at pnpm setup, not npm prefix/sudo. - pnpmHint := permissionHint("EACCES: permission denied, access '/Users/x/Library/pnpm'", "pnpm") + pnpmHint := permissionHint("EACCES: permission denied, access 'pnpm-home'", "pnpm") if !strings.Contains(pnpmHint, "pnpm setup") { t.Errorf("expected pnpm setup hint, got: %s", pnpmHint) } diff --git a/extension/README.md b/extension/README.md index be728db7c2..30ea3ade06 100644 --- a/extension/README.md +++ b/extension/README.md @@ -7,7 +7,13 @@ Main extension points: | Package | Extension point | What it does | | ------- | --------------- | ------------ | | [`credential/`](./credential/) | **Credential** | Bring your own credential source: database, Vault, config center… | -| [`transport/`](./transport/) | **Transport** | Intercept every HTTP request: inject headers, rewrite targets, logging & monitoring | +| [`transport/`](./transport/) | **Transport** | Intercept HTTP requests and rewrite CLI-owned network, presentation, and child-process URLs | | [`platform/`](./platform/) | **Restrict · Observer · Wrap · On** | Command allow/deny rules, audit hooks, onion-style middleware (approval gates, rate limiting), process lifecycle — see the [Plugin SDK README](./platform/README.md) | 📖 Full guide: [Embed lark-cli in your Agent](https://open.larksuite.com/document/mcp_open_tools/feishu-cli/embed-feishu-cli-in-agent) ([中文](https://open.larkoffice.com/document/mcp_open_tools/feishu-cli/embed-feishu-cli-in-agent)) + +The transport registry has one process-wide owner. Register the aggregate +provider during `init`, before constructing or executing the CLI. URL rewriting +runs before the request interceptor and also covers CLI-owned presentation URLs +and URLs passed to child processes. `ScopedProvider` limits only the request +interceptor. diff --git a/extension/transport/registry.go b/extension/transport/registry.go index d034b14b3d..d7ccf126e5 100644 --- a/extension/transport/registry.go +++ b/extension/transport/registry.go @@ -10,9 +10,13 @@ var ( provider Provider ) -// Register registers a transport Provider. -// Later registrations override earlier ones. -// Typically called from init() via blank import. +// Register sets the process-wide transport Provider. +// +// Integrations that need multiple capabilities compose them in one Provider +// and register it during init, before command construction or execution. Later +// registrations replace the earlier Provider for backward compatibility; +// changing the Provider while the CLI is running is unsupported because +// clients may already hold a resolved interceptor or URL rewriter. func Register(p Provider) { mu.Lock() defer mu.Unlock() diff --git a/extension/transport/types.go b/extension/transport/types.go index 61c6f04420..dcc7d9cf6b 100644 --- a/extension/transport/types.go +++ b/extension/transport/types.go @@ -15,6 +15,22 @@ type Provider interface { ResolveInterceptor(ctx context.Context) Interceptor } +// URLRewriter maps a URL to the URL that lark-cli should use. +// Returning the input unchanged means no rewrite. +type URLRewriter interface { + RewriteURL(rawURL string) string +} + +// URLRewriterProvider optionally supplies URL rewriting in addition to the +// existing request interceptor. ResolveURLRewriter must be a fast, local +// lookup; it may run while the CLI is constructing an HTTP client or rendering +// a non-network URL. Providers that do not implement this interface retain +// their existing behavior. +type URLRewriterProvider interface { + Provider + ResolveURLRewriter(ctx context.Context) URLRewriter +} + // RequestClass describes the trust boundary of an outbound HTTP request. // Platform requests target endpoints owned by the CLI's endpoint resolver; // external requests target user-provided, pre-signed, CDN, registry, or other @@ -28,9 +44,11 @@ const ( RequestClassExternal RequestClass = "external" ) -// ScopedProvider optionally limits a Provider to selected request classes. -// Providers that do not implement this interface retain the original -// behavior and apply to every request class. +// ScopedProvider optionally limits the request Interceptor to selected request +// classes. URL rewriting is intentionally not scoped: it also applies to +// presentation URLs and URLs passed to child processes, which have no request +// class. Providers that do not implement this interface retain the original +// interceptor behavior and apply to every request class. type ScopedProvider interface { Provider SupportsRequestClass(RequestClass) bool diff --git a/internal/errclass/classify.go b/internal/errclass/classify.go index 4418d756e8..3f2165dc9e 100644 --- a/internal/errclass/classify.go +++ b/internal/errclass/classify.go @@ -12,6 +12,7 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/recovery" + "github.com/larksuite/cli/internal/urlrewrite" ) // ClassifyContext is the contextual data BuildAPIError uses to populate @@ -569,9 +570,9 @@ func ConsoleURL(brand, appID string, scopes []string) string { base := fmt.Sprintf("%s/page/scope-apply?clientID=%s", core.ResolveOpenBaseURL(core.ParseBrand(brand)), url.QueryEscape(appID)) if len(scopes) == 0 { - return base + return urlrewrite.Rewrite(base) } - return base + "&scopes=" + url.QueryEscape(strings.Join(scopes, ",")) + return urlrewrite.Rewrite(base + "&scopes=" + url.QueryEscape(strings.Join(scopes, ","))) } func intFromAny(v any) int { diff --git a/internal/qualitygate/config/allowlists/public-domains.txt b/internal/qualitygate/config/allowlists/public-domains.txt index 3bf6e32c4a..d5fc8e09e1 100644 --- a/internal/qualitygate/config/allowlists/public-domains.txt +++ b/internal/qualitygate/config/allowlists/public-domains.txt @@ -4,6 +4,7 @@ accounts.larksuite.com applink.feishu.cn applink.larksuite.com ark.ap-southeast.bytepluses.com +docs.npmjs.com github.com larkoffice.com lf-larkemail.bytetos.com @@ -11,6 +12,7 @@ mcp.feishu.cn mcp.larksuite.com open.feishu.cn open.larksuite.com +pnpm.io registry.npmjs.org registry.npmmirror.com sf16-sg.tiktokcdn.com diff --git a/internal/registry/scope_hint.go b/internal/registry/scope_hint.go index c1af42d9ff..4b58b31a84 100644 --- a/internal/registry/scope_hint.go +++ b/internal/registry/scope_hint.go @@ -8,6 +8,7 @@ import ( "net/url" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/urlrewrite" ) // ExtractRequiredScopes pulls scope names out of the API error's @@ -59,10 +60,10 @@ func BuildConsoleScopeURL(brand core.LarkBrand, appID, scope string) string { if appID == "" || scope == "" { return "" } - return fmt.Sprintf( + return urlrewrite.Rewrite(fmt.Sprintf( "%s/page/scope-apply?clientID=%s&scopes=%s", core.ResolveOpenBaseURL(brand), url.QueryEscape(appID), url.QueryEscape(scope), - ) + )) } diff --git a/internal/selfupdate/updater.go b/internal/selfupdate/updater.go index 804d34f7d0..5cbf705108 100644 --- a/internal/selfupdate/updater.go +++ b/internal/selfupdate/updater.go @@ -18,6 +18,7 @@ import ( "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/transport" + "github.com/larksuite/cli/internal/urlrewrite" "github.com/larksuite/cli/internal/vfs" ) @@ -342,6 +343,7 @@ func (u *Updater) InstallAllSkills(source string) *NpmResult { } func (u *Updater) StageSuite(source, dir string) *NpmResult { + source = rewriteSkillsSource(source) suiteSource := strings.TrimSuffix(strings.TrimRight(source, "/"), "/regular") + "/isolated" return u.runSkillsCommandInDir(dir, "-y", "skills", "add", suiteSource, "-s", "lark-suite", "-y") } @@ -358,6 +360,7 @@ func (u *Updater) RemoveGlobalSkills(names []string) *NpmResult { } func (u *Updater) runSkillsAdd(source string) *NpmResult { + source = rewriteSkillsSource(source) return u.runSkillsCommand("-y", "skills", "add", source, "-g", "-y") } @@ -366,12 +369,19 @@ func (u *Updater) runSkillsListGlobal() *NpmResult { } func (u *Updater) runSkillsInstall(source string, nameList []string) *NpmResult { + source = rewriteSkillsSource(source) args := []string{"-y", "skills", "add", source, "-s"} args = append(args, nameList...) args = append(args, "-g", "-y") return u.runSkillsCommand(args...) } +// rewriteSkillsSource applies the optional URL rewriter to the CLI-owned +// skills source passed to npx or pnpm. +func rewriteSkillsSource(source string) string { + return urlrewrite.Rewrite(source) +} + // skillsInvocation decides how to launch the `skills` CLI. When the lark-cli // itself was installed via pnpm and pnpm is available, it uses `pnpm dlx` so // pnpm-only environments (pnpm's standalone installer bundles Node without diff --git a/internal/selfupdate/updater_test.go b/internal/selfupdate/updater_test.go index 548715628d..34ccf8324b 100644 --- a/internal/selfupdate/updater_test.go +++ b/internal/selfupdate/updater_test.go @@ -19,6 +19,7 @@ import ( "time" "github.com/larksuite/cli/internal/core" + testurlrewrite "github.com/larksuite/cli/internal/testutil/urlrewrite" "github.com/larksuite/cli/internal/vfs" ) @@ -178,16 +179,18 @@ func TestVerifyBinaryEmptyOutput(t *testing.T) { func TestSkillsCommandsUseExpectedArgs(t *testing.T) { tests := []struct { - name string - run func(*Updater) *NpmResult - want string + name string + rewrite bool + run func(*Updater) *NpmResult + want string }{ { - name: "stage suite", + name: "stage suite with rewritten source", + rewrite: true, run: func(u *Updater) *NpmResult { return u.StageSuite("https://open.feishu.cn/lark-cli/skills/regular", ".") }, - want: "-y skills add https://open.feishu.cn/lark-cli/skills/isolated -s lark-suite -y", + want: "-y skills add http://mirror.example.test/lark-cli/skills/isolated -s lark-suite -y", }, { name: "list global", @@ -204,11 +207,20 @@ func TestSkillsCommandsUseExpectedArgs(t *testing.T) { want: "-y skills ls -g --json", }, { - name: "install skill primary", + name: "install skill with rewritten source", + rewrite: true, run: func(u *Updater) *NpmResult { return u.runSkillsInstall("https://open.feishu.cn", []string{"lark-mail"}) }, - want: "-y skills add https://open.feishu.cn -s lark-mail -g -y", + want: "-y skills add http://mirror.example.test -s lark-mail -g -y", + }, + { + name: "install all with rewritten source", + rewrite: true, + run: func(u *Updater) *NpmResult { + return u.InstallAllSkills("https://open.feishu.cn") + }, + want: "-y skills add http://mirror.example.test -g -y", }, } @@ -224,6 +236,11 @@ func TestSkillsCommandsUseExpectedArgs(t *testing.T) { t.Fatal(err) } t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + if tt.rewrite { + testurlrewrite.Register(t, func(rawURL string) string { + return strings.Replace(rawURL, "https://open.feishu.cn", "http://mirror.example.test", 1) + }) + } result := tt.run(New()) if result.Err != nil { diff --git a/internal/testutil/urlrewrite/urlrewrite.go b/internal/testutil/urlrewrite/urlrewrite.go new file mode 100644 index 0000000000..5e631f5989 --- /dev/null +++ b/internal/testutil/urlrewrite/urlrewrite.go @@ -0,0 +1,37 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package urlrewrite installs URL rewriters for tests. +package urlrewrite + +import ( + "context" + "testing" + + exttransport "github.com/larksuite/cli/extension/transport" +) + +type provider struct { + rewriter rewriteFunc +} + +func (provider) Name() string { return "test-url-rewrite" } + +func (provider) ResolveInterceptor(context.Context) exttransport.Interceptor { return nil } + +func (p provider) ResolveURLRewriter(context.Context) exttransport.URLRewriter { + return p.rewriter +} + +type rewriteFunc func(string) string + +func (f rewriteFunc) RewriteURL(rawURL string) string { return f(rawURL) } + +// Register installs rewrite for the duration of the test. Tests using it must +// not run in parallel because the extension registry is process-wide. +func Register(t *testing.T, rewrite func(string) string) { + t.Helper() + previous := exttransport.GetProvider() + exttransport.Register(provider{rewriter: rewrite}) + t.Cleanup(func() { exttransport.Register(previous) }) +} diff --git a/internal/transport/extension.go b/internal/transport/extension.go index 0243e6ea0c..c64f5b6863 100644 --- a/internal/transport/extension.go +++ b/internal/transport/extension.go @@ -6,8 +6,10 @@ package transport import ( "context" "net/http" + "net/url" exttransport "github.com/larksuite/cli/extension/transport" + "github.com/larksuite/cli/internal/urlrewrite" ) var _ RoundTripperDecorator = (*ExtensionMiddleware)(nil) @@ -15,6 +17,7 @@ var _ RoundTripperDecorator = (*ExtensionMiddleware)(nil) type resolvedExtension struct { provider exttransport.Provider interceptor exttransport.Interceptor + rewriter *urlrewrite.Resolver } func resolveExtension() *resolvedExtension { @@ -22,11 +25,18 @@ func resolveExtension() *resolvedExtension { if p == nil { return nil } - interceptor := p.ResolveInterceptor(context.Background()) - if interceptor == nil { + + extension := &resolvedExtension{ + provider: p, + interceptor: p.ResolveInterceptor(context.Background()), + } + if _, ok := p.(exttransport.URLRewriterProvider); ok { + extension.rewriter = urlrewrite.ResolveProvider(context.Background(), p) + } + if extension.interceptor == nil && extension.rewriter == nil { return nil } - return &resolvedExtension{provider: p, interceptor: interceptor} + return extension } func (e *resolvedExtension) wrap(base http.RoundTripper, class exttransport.RequestClass, enforceScope bool) http.RoundTripper { @@ -36,16 +46,25 @@ func (e *resolvedExtension) wrap(base http.RoundTripper, class exttransport.Requ if e == nil { return base } - if enforceScope { + interceptor := e.interceptor + if enforceScope && interceptor != nil { if scoped, ok := e.provider.(exttransport.ScopedProvider); ok && !scoped.SupportsRequestClass(class) { - return base + interceptor = nil } } - return &ExtensionMiddleware{Base: base, Ext: e.interceptor, ExtName: e.provider.Name()} + if interceptor == nil && e.rewriter == nil { + return base + } + return &ExtensionMiddleware{ + Base: base, + Ext: interceptor, + ExtName: e.provider.Name(), + rewriter: e.rewriter, + } } -// ExtensionMiddleware wraps the built-in transport chain with extension -// pre/post hooks. The built-in chain always executes unless an +// ExtensionMiddleware wraps the built-in transport chain with URL rewriting +// and extension pre/post hooks. The built-in chain always executes unless an // exttransport.AbortableInterceptor rejects the request. // // The original request context is restored after the pre hook to prevent an @@ -54,9 +73,10 @@ func (e *resolvedExtension) wrap(base http.RoundTripper, class exttransport.Requ // request object. The body remains shared; interceptors that consume it must // restore it before returning. type ExtensionMiddleware struct { - Base http.RoundTripper - Ext exttransport.Interceptor - ExtName string + Base http.RoundTripper + Ext exttransport.Interceptor + ExtName string + rewriter *urlrewrite.Resolver } // BaseRoundTripper returns the wrapped built-in transport chain. @@ -80,15 +100,28 @@ func (m *ExtensionMiddleware) WithBaseRoundTripper(base http.RoundTripper) http. func (m *ExtensionMiddleware) RoundTrip(req *http.Request) (*http.Response, error) { origCtx := req.Context() req = req.Clone(origCtx) + if m.rewriter != nil { + rewritten := m.rewriter.Rewrite(req.URL.String()) + if rewritten != req.URL.String() { + rewrittenURL, err := url.Parse(rewritten) + if err != nil { + return nil, err + } + req.URL = rewrittenURL + req.Host = rewrittenURL.Host + } + } var ( post func(*http.Response, error) abortErr error ) - if a, ok := m.Ext.(exttransport.AbortableInterceptor); ok { - post, abortErr = a.PreRoundTripE(req) - } else { - post = m.Ext.PreRoundTrip(req) + if m.Ext != nil { + if a, ok := m.Ext.(exttransport.AbortableInterceptor); ok { + post, abortErr = a.PreRoundTripE(req) + } else { + post = m.Ext.PreRoundTrip(req) + } } if abortErr != nil { if post != nil { @@ -106,15 +139,16 @@ func (m *ExtensionMiddleware) RoundTrip(req *http.Request) (*http.Response, erro } // WrapWithExtension wraps base with the currently registered transport -// extension. With no registered provider or no resolved interceptor, base is -// returned unchanged. +// extension. With no registered provider, base is returned unchanged. func WrapWithExtension(base http.RoundTripper) http.RoundTripper { return resolveExtension().wrap(base, "", false) } -// WrapWithExtensionForClass wraps base only when the registered provider -// supports class. Providers without the optional ScopedProvider interface keep -// their historical all-request behavior. +// WrapWithExtensionForClass applies URL rewriting and wraps base with the +// interceptor when the registered provider supports class. ScopedProvider only +// limits the interceptor; URL rewriting remains available for every class. +// Providers without ScopedProvider keep their historical all-request +// interceptor behavior. func WrapWithExtensionForClass(base http.RoundTripper, class exttransport.RequestClass) http.RoundTripper { return resolveExtension().wrap(base, class, true) } diff --git a/internal/transport/extension_test.go b/internal/transport/extension_test.go index 5b83f7f69a..dbc9fd1027 100644 --- a/internal/transport/extension_test.go +++ b/internal/transport/extension_test.go @@ -35,6 +35,24 @@ func (p testProvider) ResolveInterceptor(context.Context) exttransport.Intercept return p.interceptor } +type rewriteTestProvider struct { + testProvider + rewriter exttransport.URLRewriter + supported exttransport.RequestClass +} + +func (p rewriteTestProvider) ResolveURLRewriter(context.Context) exttransport.URLRewriter { + return p.rewriter +} + +func (p rewriteTestProvider) SupportsRequestClass(class exttransport.RequestClass) bool { + return p.supported == "" || class == p.supported +} + +type rewriteFunc func(string) string + +func (f rewriteFunc) RewriteURL(rawURL string) string { return f(rawURL) } + type scopedTestProvider struct { testProvider supported exttransport.RequestClass @@ -46,10 +64,12 @@ func (p scopedTestProvider) SupportsRequestClass(class exttransport.RequestClass type testHeaderInterceptor struct { calls int + url string } func (i *testHeaderInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) { i.calls++ + i.url = req.URL.String() req.Header.Set("X-Test-Platform", "routed") return nil } @@ -170,6 +190,151 @@ func TestHTTPPolicyRouterResolvesProviderOnce(t *testing.T) { } } +func TestHTTPPolicyRouterRewriteOnlyProviderDoesNotMutateCaller(t *testing.T) { + registerTestProvider(t, rewriteTestProvider{ + rewriter: rewriteFunc(func(rawURL string) string { + return strings.Replace(rawURL, "source.example.test", "mirror.example.test", 1) + }), + }) + + var received *http.Request + router := NewHTTPPolicyRouter( + roundTripFunc(func(req *http.Request) (*http.Response, error) { + received = req + return noContentResponse(req), nil + }), + roundTripFunc(func(*http.Request) (*http.Response, error) { + t.Fatal("external policy selected for explicit platform request") + return nil, nil + }), + ) + + const originalURL = "https://source.example.test/open-apis/test?x=1" + req := httptest.NewRequest(http.MethodGet, originalURL, nil) + req = WithRequestClass(req, exttransport.RequestClassPlatform) + roundTripForTest(t, router, req) + + const rewrittenURL = "https://mirror.example.test/open-apis/test?x=1" + if got := received.URL.String(); got != rewrittenURL { + t.Fatalf("base URL = %q, want %q", got, rewrittenURL) + } + if received.Host != "mirror.example.test" { + t.Fatalf("base Host = %q, want rewritten host", received.Host) + } + if got := req.URL.String(); got != originalURL { + t.Fatalf("caller request URL = %q, want %q", got, originalURL) + } + if got := req.Host; got != "source.example.test" { + t.Fatalf("caller request Host = %q, want original host", got) + } +} + +func TestHTTPPolicyRouterInterceptorObservesRewrittenURL(t *testing.T) { + interceptor := &testHeaderInterceptor{} + registerTestProvider(t, rewriteTestProvider{ + testProvider: testProvider{interceptor: interceptor}, + rewriter: rewriteFunc(func(rawURL string) string { + return strings.Replace(rawURL, "source.example.test", "mirror.example.test", 1) + }), + }) + + base := roundTripFunc(func(req *http.Request) (*http.Response, error) { + return noContentResponse(req), nil + }) + req := httptest.NewRequest(http.MethodGet, "https://source.example.test/path", nil) + roundTripForTest(t, WrapWithExtension(base), req) + + if interceptor.url != "https://mirror.example.test/path" { + t.Fatalf("interceptor URL = %q, want rewritten URL", interceptor.url) + } +} + +func TestHTTPPolicyRouterClassifiesOriginalURLsAndScopesOnlyInterceptor(t *testing.T) { + interceptor := &testHeaderInterceptor{} + registerTestProvider(t, rewriteTestProvider{ + testProvider: testProvider{interceptor: interceptor}, + rewriter: rewriteFunc(func(rawURL string) string { + rawURL = strings.Replace(rawURL, "open.feishu.cn", "open.mirror.test", 1) + return strings.Replace(rawURL, ".example.test", ".mirror.test", 1) + }), + supported: exttransport.RequestClassPlatform, + }) + + type receivedRequest struct { + url string + header string + } + var platform, external receivedRequest + router := NewHTTPPolicyRouter( + roundTripFunc(func(req *http.Request) (*http.Response, error) { + platform = receivedRequest{url: req.URL.String(), header: req.Header.Get("X-Test-Platform")} + return noContentResponse(req), nil + }), + roundTripFunc(func(req *http.Request) (*http.Response, error) { + external = receivedRequest{url: req.URL.String(), header: req.Header.Get("X-Test-Platform")} + return noContentResponse(req), nil + }), + ) + + for _, rawURL := range []string{ + "https://open.feishu.cn/open-apis/test", + "https://external.example.test/file", + } { + req := httptest.NewRequest(http.MethodGet, rawURL, nil) + roundTripForTest(t, router, req) + } + + if want := (receivedRequest{url: "https://open.mirror.test/open-apis/test", header: "routed"}); platform != want { + t.Fatalf("platform request = %#v, want %#v", platform, want) + } + if want := (receivedRequest{url: "https://external.mirror.test/file"}); external != want { + t.Fatalf("external request = %#v, want %#v", external, want) + } + if interceptor.calls != 1 { + t.Fatalf("interceptor calls = %d, want platform only", interceptor.calls) + } +} + +func TestHTTPPolicyRouterRejectsUnparsableRewriteBeforeBase(t *testing.T) { + registerTestProvider(t, rewriteTestProvider{ + rewriter: rewriteFunc(func(string) string { return "http://[::1" }), + }) + + baseCalls := 0 + base := roundTripFunc(func(*http.Request) (*http.Response, error) { + baseCalls++ + return nil, nil + }) + router := NewHTTPPolicyRouter(base, base) + req := httptest.NewRequest(http.MethodGet, "https://example.test/path", nil) + resp, err := router.RoundTrip(req) + if resp != nil { + t.Fatalf("response = %v, want nil", resp) + } + if err == nil { + t.Fatal("RoundTrip() error = nil, want URL parse error") + } + if baseCalls != 0 { + t.Fatalf("base calls = %d, want 0", baseCalls) + } +} + +func registerTestProvider(t *testing.T, provider exttransport.Provider) { + t.Helper() + previous := exttransport.GetProvider() + exttransport.Register(provider) + t.Cleanup(func() { exttransport.Register(previous) }) +} + +func roundTripForTest(t *testing.T, transport http.RoundTripper, req *http.Request) { + t.Helper() + resp, err := transport.RoundTrip(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() +} + func TestSDKBootstrapBridgeBlocksCrossOriginRedirectAfterSameOriginHop(t *testing.T) { var externalCalls atomic.Int32 var relayBody string diff --git a/internal/urlrewrite/rewrite.go b/internal/urlrewrite/rewrite.go new file mode 100644 index 0000000000..2bac58d95a --- /dev/null +++ b/internal/urlrewrite/rewrite.go @@ -0,0 +1,54 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package urlrewrite resolves and applies the optional URL rewrite extension. +package urlrewrite + +import ( + "context" + + exttransport "github.com/larksuite/cli/extension/transport" +) + +// Resolver holds the URL rewriter resolved for one caller. +// +// A nil rewriter is an identity resolver. Resolve once when a caller needs to +// apply the same extension to multiple URLs. +type Resolver struct { + rewriter exttransport.URLRewriter +} + +// Resolve resolves the URL rewriter from the registered transport provider. +// Providers that do not implement URLRewriterProvider, and providers that +// return a nil rewriter, produce an identity resolver. +func Resolve(ctx context.Context) *Resolver { + return ResolveProvider(ctx, exttransport.GetProvider()) +} + +// ResolveProvider resolves the URL rewriter from p. Callers that have already +// selected a provider should use this function so related extension hooks use +// the same provider instance. +func ResolveProvider(ctx context.Context, provider exttransport.Provider) *Resolver { + p, ok := provider.(exttransport.URLRewriterProvider) + if !ok { + return &Resolver{} + } + return &Resolver{rewriter: p.ResolveURLRewriter(ctx)} +} + +// Rewrite resolves the registered URL rewriter with a background context and +// applies it to rawURL. Rewriting is a synchronous in-process string mapping; +// callers that already captured a provider can use ResolveProvider instead. +func Rewrite(rawURL string) string { + return Resolve(context.Background()).Rewrite(rawURL) +} + +// Rewrite applies the resolved URL rewriter to rawURL. The extension is trusted +// in-process code and owns the returned value; URL-consuming call sites apply +// their existing parsing and transport behavior. +func (r *Resolver) Rewrite(rawURL string) string { + if r == nil || r.rewriter == nil { + return rawURL + } + return r.rewriter.RewriteURL(rawURL) +} diff --git a/internal/urlrewrite/rewrite_test.go b/internal/urlrewrite/rewrite_test.go new file mode 100644 index 0000000000..f6ba3cb5cd --- /dev/null +++ b/internal/urlrewrite/rewrite_test.go @@ -0,0 +1,48 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package urlrewrite + +import ( + "context" + "testing" + + exttransport "github.com/larksuite/cli/extension/transport" +) + +type testProvider struct { + rewriter exttransport.URLRewriter +} + +func (testProvider) Name() string { return "test" } +func (testProvider) ResolveInterceptor(context.Context) exttransport.Interceptor { return nil } +func (p testProvider) ResolveURLRewriter(context.Context) exttransport.URLRewriter { return p.rewriter } + +type rewriteFunc func(string) string + +func (f rewriteFunc) RewriteURL(rawURL string) string { return f(rawURL) } + +func TestResolveProvider(t *testing.T) { + const raw = "https://example.test/a%2Fb?x=1+2" + for _, provider := range []exttransport.Provider{nil, testProvider{}} { + if got := ResolveProvider(context.Background(), provider).Rewrite(raw); got != raw { + t.Fatalf("identity Rewrite() = %q, want %q", got, raw) + } + } + + provider := testProvider{rewriter: rewriteFunc(func(string) string { return "/rewritten" })} + if got := ResolveProvider(context.Background(), provider).Rewrite(raw); got != "/rewritten" { + t.Fatalf("Rewrite() = %q, want extension value", got) + } +} + +func TestRewriteUsesRegisteredProvider(t *testing.T) { + const rewritten = "/extension-owned/value" + previous := exttransport.GetProvider() + exttransport.Register(testProvider{rewriter: rewriteFunc(func(string) string { return rewritten })}) + t.Cleanup(func() { exttransport.Register(previous) }) + + if got := Rewrite("https://source.example.test/path"); got != rewritten { + t.Fatalf("Rewrite() = %q, want %q", got, rewritten) + } +} diff --git a/shortcuts/apps/apps_init.go b/shortcuts/apps/apps_init.go index 4fd250295f..d412748ef3 100644 --- a/shortcuts/apps/apps_init.go +++ b/shortcuts/apps/apps_init.go @@ -17,6 +17,7 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/charcheck" + "github.com/larksuite/cli/internal/urlrewrite" "github.com/larksuite/cli/shortcuts/common" ) @@ -408,12 +409,13 @@ func isEmptyRepo(ctx context.Context, dir string) (bool, error) { // Empty repo -> `app init`; non-empty -> `app sync` + meta app_id patch + // conditional `skills sync`. Returns "init" or "upgrade". func runScaffold(ctx context.Context, dir, appID, appType, sourcePath string) (string, error) { + registry := urlrewrite.Rewrite(npmRegistry) empty, err := isEmptyRepo(ctx, dir) if err != nil { return "", err } if empty { - args := scaffoldInitArgs(appType, appID, sourcePath) + args := scaffoldInitArgsWithRegistry(registry, appType, appID, sourcePath) if _, stderr, err := initRunner.Run(ctx, dir, "npx", args...); err != nil { return "", appsExternalToolError(err, "npx app init failed: %s", gitErr(stderr, err)) } @@ -421,7 +423,7 @@ func runScaffold(ctx context.Context, dir, appID, appType, sourcePath string) (s } policy := policyForAppType(appType) if !policy.skipAppSync { - if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, "app", "sync"); err != nil { + if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", "--registry", registry, miaodaCLIPkg, "app", "sync"); err != nil { return "", appsExternalToolError(err, "npx app sync failed: %s", gitErr(stderr, err)) } } @@ -429,7 +431,7 @@ func runScaffold(ctx context.Context, dir, appID, appType, sourcePath string) (s return "", err } if !policy.skipSkillsSync && !hasSteeringSkills(dir) { - if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, "skills", "sync", "--local"); err != nil { + if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", "--registry", registry, miaodaCLIPkg, "skills", "sync", "--local"); err != nil { return "", appsExternalToolError(err, "npx skills sync failed: %s", gitErr(stderr, err)) } } @@ -446,7 +448,11 @@ func runScaffold(ctx context.Context, dir, appID, appType, sourcePath string) (s // translate the app type; mapping the app type to a concrete tech stack is the // downstream tool's responsibility. func scaffoldInitArgs(appType, appID, sourcePath string) []string { - base := []string{"-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, "app", "init"} + return scaffoldInitArgsWithRegistry(npmRegistry, appType, appID, sourcePath) +} + +func scaffoldInitArgsWithRegistry(registry, appType, appID, sourcePath string) []string { + base := []string{"-y", "--prefer-online", "--registry", registry, miaodaCLIPkg, "app", "init"} at := appType if at == "" { at = "full_stack" diff --git a/shortcuts/apps/apps_init_test.go b/shortcuts/apps/apps_init_test.go index 04f0dbb331..348ad1fc3e 100644 --- a/shortcuts/apps/apps_init_test.go +++ b/shortcuts/apps/apps_init_test.go @@ -22,6 +22,7 @@ import ( "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/internal/testutil/gitcmd" + testurlrewrite "github.com/larksuite/cli/internal/testutil/urlrewrite" "github.com/larksuite/cli/shortcuts/common" ) @@ -277,11 +278,14 @@ func TestRunScaffold_NonEmpty_SyncsWhenNoSteering(t *testing.T) { dir := t.TempDir() // no steering dir, no meta.json f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: "src/x.ts\n"}}} withFakeRunner(t, f) + testurlrewrite.Register(t, func(rawURL string) string { + return strings.Replace(rawURL, npmRegistry, "http://registry.example.test", 1) + }) kind, err := runScaffold(context.Background(), dir, "app_x", "", "") if err != nil || kind != "upgrade" { t.Fatalf("kind=%q err=%v, want upgrade", kind, err) } - if c := findCallArg(f.calls, "npx", "app", "sync"); c == nil || !containsAll(c, "-y", "--prefer-online") { + if c := findCallArg(f.calls, "npx", "app", "sync"); c == nil || !containsAll(c, "-y", "--prefer-online", "--registry", "http://registry.example.test") { t.Error("app sync not invoked with --prefer-online") } else if containsAll(c, "--local") { t.Errorf("app sync must NOT carry --local: %v", c) diff --git a/shortcuts/calendar/description_rich_images.go b/shortcuts/calendar/description_rich_images.go index 719e24ea2d..df1694b783 100644 --- a/shortcuts/calendar/description_rich_images.go +++ b/shortcuts/calendar/description_rich_images.go @@ -19,6 +19,7 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/urlrewrite" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -168,5 +169,5 @@ func buildCalendarImagePreviewURL(brand core.LarkBrand, fileToken string, width, if size > 0 { u += fmt.Sprintf("&im_size=%d", size) } - return u + return urlrewrite.Rewrite(u) } diff --git a/shortcuts/common/resource_url.go b/shortcuts/common/resource_url.go index 29ec31c10e..6a3c9f16bb 100644 --- a/shortcuts/common/resource_url.go +++ b/shortcuts/common/resource_url.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/urlrewrite" ) // BuildResourceURL returns a brand-standard, user-facing URL for a freshly @@ -33,28 +34,30 @@ func BuildResourceURL(brand core.LarkBrand, kind, token string) string { host = "https://www.larksuite.com" } + var resourceURL string switch strings.ToLower(strings.TrimSpace(kind)) { case "docx": - return host + "/docx/" + token + resourceURL = host + "/docx/" + token case "doc": - return host + "/doc/" + token + resourceURL = host + "/doc/" + token case "sheet": - return host + "/sheets/" + token + resourceURL = host + "/sheets/" + token case "bitable": - return host + "/base/" + token + resourceURL = host + "/base/" + token case "wiki": - return host + "/wiki/" + token + resourceURL = host + "/wiki/" + token case "file": - return host + "/file/" + token + resourceURL = host + "/file/" + token case "folder": - return host + "/drive/folder/" + token + resourceURL = host + "/drive/folder/" + token case "mindnote": - return host + "/mindnote/" + token + resourceURL = host + "/mindnote/" + token case "slides": - return host + "/slides/" + token + resourceURL = host + "/slides/" + token default: return "" } + return urlrewrite.Rewrite(resourceURL) } // ResourceRef holds the parsed type and token from a Lark resource URL. diff --git a/shortcuts/doc/docs_fetch_im_markdown.go b/shortcuts/doc/docs_fetch_im_markdown.go index 7c94127027..8ca47cd280 100644 --- a/shortcuts/doc/docs_fetch_im_markdown.go +++ b/shortcuts/doc/docs_fetch_im_markdown.go @@ -10,6 +10,8 @@ import ( "regexp" "strings" "unicode/utf8" + + "github.com/larksuite/cli/internal/urlrewrite" ) type imMarkdownContext struct { @@ -118,6 +120,8 @@ func newIMMarkdownContext(docInput string) imMarkdownContext { raw := strings.TrimSpace(docInput) if extracted, ok := imMarkdownBaseURLFromInput(raw); ok { base = extracted + } else { + base = urlrewrite.Rewrite(base) } return imMarkdownContext{baseURL: base} } diff --git a/shortcuts/doc/docs_fetch_im_markdown_test.go b/shortcuts/doc/docs_fetch_im_markdown_test.go index 262c48ed34..4afd2ecacd 100644 --- a/shortcuts/doc/docs_fetch_im_markdown_test.go +++ b/shortcuts/doc/docs_fetch_im_markdown_test.go @@ -7,6 +7,8 @@ import ( "reflect" "strings" "testing" + + testurlrewrite "github.com/larksuite/cli/internal/testutil/urlrewrite" ) func TestApplyFetchIMMarkdown(t *testing.T) { @@ -70,6 +72,19 @@ func TestApplyFetchIMMarkdown(t *testing.T) { } } +func TestNewIMMarkdownContextRewritesFallbackURL(t *testing.T) { + testurlrewrite.Register(t, func(raw string) string { + if raw == "https://larkoffice.com" { + return "https://tenant.example.com/base" + } + return raw + }) + + if got := newIMMarkdownContext("doc_token").baseURL; got != "https://tenant.example.com/base" { + t.Fatalf("baseURL = %q", got) + } +} + func TestConvertToIMMarkdownTitle(t *testing.T) { t.Parallel() @@ -1076,7 +1091,8 @@ func TestNewIMMarkdownContextExtractsBaseURL(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - if got := newIMMarkdownContext(tt.input).baseURL; got != tt.want { + imCtx := newIMMarkdownContext(tt.input) + if got := imCtx.baseURL; got != tt.want { t.Fatalf("baseURL = %q, want %q", got, tt.want) } }) diff --git a/shortcuts/drive/drive_permission_get_setting.go b/shortcuts/drive/drive_permission_get_setting.go index aae57ffb77..11b801e6fa 100644 --- a/shortcuts/drive/drive_permission_get_setting.go +++ b/shortcuts/drive/drive_permission_get_setting.go @@ -13,6 +13,7 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/urlrewrite" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -178,7 +179,7 @@ func (s drivePermissionGetSettingSpec) url(runtime *common.RuntimeContext) strin if brand == core.BrandLark { host = "https://www.larksuite.com" } - return host + resourceKind.CanonicalPath + url.PathEscape(token) + return urlrewrite.Rewrite(host + resourceKind.CanonicalPath + url.PathEscape(token)) } func validateDrivePermissionGetSettingToken(token string) error { diff --git a/shortcuts/drive/drive_permission_get_setting_test.go b/shortcuts/drive/drive_permission_get_setting_test.go index 80fc71a223..8a01608c4a 100644 --- a/shortcuts/drive/drive_permission_get_setting_test.go +++ b/shortcuts/drive/drive_permission_get_setting_test.go @@ -192,7 +192,8 @@ func TestDrivePermissionGetSettingResourceURLUsesConfiguredBrand(t *testing.T) { if err != nil { t.Fatalf("read spec: %v", err) } - if got, want := spec.url(runtime), "https://www.larksuite.com/page/appMetaTok"; got != want { + got := spec.url(runtime) + if want := "https://www.larksuite.com/page/appMetaTok"; got != want { t.Fatalf("resource URL = %q, want %q", got, want) } } diff --git a/shortcuts/im/chat_app_link.go b/shortcuts/im/chat_app_link.go index a07522aaee..05be024428 100644 --- a/shortcuts/im/chat_app_link.go +++ b/shortcuts/im/chat_app_link.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/urlrewrite" "github.com/larksuite/cli/shortcuts/common" ) @@ -37,7 +38,7 @@ func assembleChatAppLink(rawChatID interface{}, brand core.LarkBrand) string { q := url.Values{} q.Set("openChatId", chatID) u.RawQuery = q.Encode() - return u.String() + return urlrewrite.Rewrite(u.String()) } func resolveChatAppLinkDomain(brand core.LarkBrand) string { diff --git a/shortcuts/im/chat_app_link_test.go b/shortcuts/im/chat_app_link_test.go index 307219ea14..04c25f47be 100644 --- a/shortcuts/im/chat_app_link_test.go +++ b/shortcuts/im/chat_app_link_test.go @@ -47,7 +47,8 @@ func TestAssembleChatAppLink(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := assembleChatAppLink(tt.chatID, tt.brand); got != tt.want { + got := assembleChatAppLink(tt.chatID, tt.brand) + if got != tt.want { t.Fatalf("assembleChatAppLink() = %q, want %q", got, tt.want) } }) diff --git a/shortcuts/im/convert_lib/content_convert.go b/shortcuts/im/convert_lib/content_convert.go index 1292b7d33c..5395167c25 100644 --- a/shortcuts/im/convert_lib/content_convert.go +++ b/shortcuts/im/convert_lib/content_convert.go @@ -13,6 +13,7 @@ import ( "strings" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/urlrewrite" "github.com/larksuite/cli/shortcuts/common" ) @@ -283,7 +284,7 @@ func assembleMessageAppLink(m map[string]interface{}, brand core.LarkBrand) stri q.Set("open_chat_id", chatID) q.Set("thread_position", threadPos) u.RawQuery = q.Encode() - return u.String() + return urlrewrite.Rewrite(u.String()) } if chatID != "" && okMsgPos { u := &url.URL{Scheme: "https", Host: domain, Path: "/client/chat/open"} @@ -291,7 +292,7 @@ func assembleMessageAppLink(m map[string]interface{}, brand core.LarkBrand) stri q.Set("openChatId", chatID) q.Set("position", msgPos) u.RawQuery = q.Encode() - return u.String() + return urlrewrite.Rewrite(u.String()) } return "" } diff --git a/shortcuts/im/im_chat_messages_list.go b/shortcuts/im/im_chat_messages_list.go index 8e3114216f..8aa1d49f25 100644 --- a/shortcuts/im/im_chat_messages_list.go +++ b/shortcuts/im/im_chat_messages_list.go @@ -160,7 +160,8 @@ var ImChatMessageList = common.Shortcut{ downloadResources := runtime.Bool("download-resources") messages := make([]map[string]interface{}, 0, len(rawItems)) for _, m := range result.items { - messages = append(messages, convertlib.FormatMessageItemWithMergePrefetchOpts(m, runtime, nameCache, mergePrefetch, downloadResources)) + message := convertlib.FormatMessageItemWithMergePrefetchOpts(m, runtime, nameCache, mergePrefetch, downloadResources) + messages = append(messages, message) } // Enrich: resolve sender names for outer messages (reuses cache from merge_forward) diff --git a/shortcuts/im/im_messages_mget.go b/shortcuts/im/im_messages_mget.go index d13b487852..cf0ac82d49 100644 --- a/shortcuts/im/im_messages_mget.go +++ b/shortcuts/im/im_messages_mget.go @@ -83,7 +83,8 @@ var ImMessagesMGet = common.Shortcut{ messages := make([]map[string]interface{}, 0, len(rawItems)) for _, item := range rawItems { m, _ := item.(map[string]interface{}) - messages = append(messages, convertlib.FormatMessageItemWithMergePrefetchOpts(m, runtime, nameCache, mergePrefetch, downloadResources)) + message := convertlib.FormatMessageItemWithMergePrefetchOpts(m, runtime, nameCache, mergePrefetch, downloadResources) + messages = append(messages, message) } convertlib.ResolveSenderNames(runtime, messages, nameCache) diff --git a/shortcuts/im/im_threads_messages_list.go b/shortcuts/im/im_threads_messages_list.go index 0c24ad2e43..46d86869b1 100644 --- a/shortcuts/im/im_threads_messages_list.go +++ b/shortcuts/im/im_threads_messages_list.go @@ -133,7 +133,8 @@ var ImThreadsMessagesList = common.Shortcut{ downloadResources := runtime.Bool("download-resources") messages := make([]map[string]interface{}, 0, len(rawItems)) for _, m := range result.items { - messages = append(messages, convertlib.FormatMessageItemWithMergePrefetchOpts(m, runtime, nameCache, mergePrefetch, downloadResources)) + message := convertlib.FormatMessageItemWithMergePrefetchOpts(m, runtime, nameCache, mergePrefetch, downloadResources) + messages = append(messages, message) } // Enrich: resolve sender names for outer messages (reuses cache from merge_forward) diff --git a/shortcuts/mail/large_attachment.go b/shortcuts/mail/large_attachment.go index 97536fa6a2..0df0a8d6b4 100644 --- a/shortcuts/mail/large_attachment.go +++ b/shortcuts/mail/large_attachment.go @@ -17,6 +17,7 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/urlrewrite" "github.com/larksuite/cli/shortcuts/common" draftpkg "github.com/larksuite/cli/shortcuts/mail/draft" "github.com/larksuite/cli/shortcuts/mail/emlbuilder" @@ -271,10 +272,10 @@ func buildLargeAttachmentItems(brand core.LarkBrand, lang string, results []larg var items strings.Builder for _, att := range results { fmt.Fprintf(&items, largeAttItemTpl, - htmlEscape(iconCDN+fileTypeIcon(att.FileName)), + htmlEscape(urlrewrite.Rewrite(iconCDN+fileTypeIcon(att.FileName))), htmlEscape(att.FileName), htmlEscape(common.FormatSize(att.FileSize)), - htmlEscape(buildLargeAttachmentPreviewURL(brand, att.FileToken)), + htmlEscape(urlrewrite.Rewrite(buildLargeAttachmentPreviewURL(brand, att.FileToken))), htmlEscape(att.FileToken), downloadText, ) @@ -320,7 +321,7 @@ func buildLargeAttachmentPlainText(brand core.LarkBrand, lang string, results [] sb.WriteString("\n") sb.WriteString(common.FormatSize(att.FileSize)) sb.WriteString("\n") - sb.WriteString(downloadText + ": " + buildLargeAttachmentPreviewURL(brand, att.FileToken)) + sb.WriteString(downloadText + ": " + urlrewrite.Rewrite(buildLargeAttachmentPreviewURL(brand, att.FileToken))) if i < len(results)-1 { sb.WriteString("\n\n") } else { diff --git a/shortcuts/mail/large_attachment_test.go b/shortcuts/mail/large_attachment_test.go index a0aa34ada7..1d90544692 100644 --- a/shortcuts/mail/large_attachment_test.go +++ b/shortcuts/mail/large_attachment_test.go @@ -13,6 +13,7 @@ import ( "github.com/spf13/cobra" "github.com/larksuite/cli/internal/core" + testurlrewrite "github.com/larksuite/cli/internal/testutil/urlrewrite" "github.com/larksuite/cli/internal/vfs/localfileio" "github.com/larksuite/cli/shortcuts/common" draftpkg "github.com/larksuite/cli/shortcuts/mail/draft" @@ -121,6 +122,9 @@ func TestBuildLargeAttachmentPreviewURL(t *testing.T) { } func TestBuildLargeAttachmentHTML(t *testing.T) { + testurlrewrite.Register(t, func(rawURL string) string { + return strings.Replace(rawURL, "https://", "https://mirror.example/", 1) + }) results := []largeAttachmentResult{ {FileName: "report.pdf", FileSize: 50 * 1024 * 1024, FileToken: "tok_abc"}, {FileName: "data.zip", FileSize: 100 * 1024 * 1024, FileToken: "tok_xyz"}, @@ -143,9 +147,12 @@ func TestBuildLargeAttachmentHTML(t *testing.T) { t.Error("missing data-mail-token for tok_abc") } // Check download links - if !strings.Contains(html, "www.feishu.cn/mail/page/attachment?token=tok_abc") { + if !strings.Contains(html, "mirror.example/www.feishu.cn/mail/page/attachment?token=tok_abc") { t.Error("missing download link for tok_abc") } + if !strings.Contains(html, "mirror.example/lf-larkemail.bytetos.com/") { + t.Error("missing rewritten icon URL") + } if !strings.Contains(html, ">Download<") { t.Error("missing English download text") } @@ -466,6 +473,9 @@ func TestEnsureLargeAttachmentCards_PlainTextNoDuplicate(t *testing.T) { } func TestBuildLargeAttachmentPlainText(t *testing.T) { + testurlrewrite.Register(t, func(rawURL string) string { + return strings.Replace(rawURL, "https://", "https://mirror.example/", 1) + }) results := []largeAttachmentResult{ {FileName: "report.pdf", FileSize: 26214400, FileToken: "tok_aaa"}, {FileName: "video.mp4", FileSize: 314572800, FileToken: "tok_bbb"}, @@ -489,6 +499,9 @@ func TestBuildLargeAttachmentPlainText(t *testing.T) { if !strings.Contains(text, "tok_bbb") { t.Error("should contain second token in URL") } + if !strings.Contains(text, "mirror.example/www.feishu.cn/mail/page/attachment") { + t.Error("should contain rewritten download URL") + } if !strings.Contains(text, "下载:") { t.Error("should contain Chinese download label") } diff --git a/shortcuts/okr/okr_progress_create.go b/shortcuts/okr/okr_progress_create.go index 3a56d5d9df..9ff36df970 100644 --- a/shortcuts/okr/okr_progress_create.go +++ b/shortcuts/okr/okr_progress_create.go @@ -14,6 +14,7 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/urlrewrite" "github.com/larksuite/cli/shortcuts/common" ) @@ -78,7 +79,7 @@ func parseCreateProgressRecordParams(runtime *common.RuntimeContext) (*createPro sourceURL := runtime.Str("source-url") if sourceURL == "" { - sourceURL = core.ResolveOpenBaseURL(runtime.Config.Brand) + "/app" + sourceURL = urlrewrite.Rewrite(core.ResolveOpenBaseURL(runtime.Config.Brand) + "/app") } var progressRate *ProgressRateV1 diff --git a/shortcuts/wiki/wiki_node_create_test.go b/shortcuts/wiki/wiki_node_create_test.go index d4be465877..66a5f6be1d 100644 --- a/shortcuts/wiki/wiki_node_create_test.go +++ b/shortcuts/wiki/wiki_node_create_test.go @@ -896,7 +896,8 @@ func TestWikiNodeURL(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - if got := wikiNodeURL(core.BrandFeishu, tc.node); got != tc.want { + got := wikiNodeURL(core.BrandFeishu, tc.node) + if got != tc.want { t.Fatalf("wikiNodeURL() = %q, want %q", got, tc.want) } }) From 8c926a63fcfa317c2aa9066ed05dfa3e93ad2a37 Mon Sep 17 00:00:00 2001 From: "guokexin.02" <264159873+Tantanz20020918@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:59:01 +0800 Subject: [PATCH 2/8] fix: preserve URL rewrite boundaries --- extension/transport/types.go | 10 ++++---- internal/selfupdate/updater.go | 2 +- internal/selfupdate/updater_test.go | 3 +++ internal/transport/extension.go | 26 ++++++++++++------- internal/transport/extension_test.go | 8 +++--- internal/update/update.go | 3 ++- internal/update/update_test.go | 38 +++++++++++++++++++--------- 7 files changed, 58 insertions(+), 32 deletions(-) diff --git a/extension/transport/types.go b/extension/transport/types.go index dcc7d9cf6b..4969205c9e 100644 --- a/extension/transport/types.go +++ b/extension/transport/types.go @@ -15,7 +15,7 @@ type Provider interface { ResolveInterceptor(ctx context.Context) Interceptor } -// URLRewriter maps a URL to the URL that lark-cli should use. +// URLRewriter maps a CLI-owned URL to the URL that lark-cli should use. // Returning the input unchanged means no rewrite. type URLRewriter interface { RewriteURL(rawURL string) string @@ -45,10 +45,10 @@ const ( ) // ScopedProvider optionally limits the request Interceptor to selected request -// classes. URL rewriting is intentionally not scoped: it also applies to -// presentation URLs and URLs passed to child processes, which have no request -// class. Providers that do not implement this interface retain the original -// interceptor behavior and apply to every request class. +// classes. URL rewriting is applied separately at CLI-owned URL boundaries, +// including presentation URLs and URLs passed to child processes, which have +// no request class. Providers that do not implement this interface retain the +// original interceptor behavior and apply to every request class. type ScopedProvider interface { Provider SupportsRequestClass(RequestClass) bool diff --git a/internal/selfupdate/updater.go b/internal/selfupdate/updater.go index 5cbf705108..67caaa4845 100644 --- a/internal/selfupdate/updater.go +++ b/internal/selfupdate/updater.go @@ -343,8 +343,8 @@ func (u *Updater) InstallAllSkills(source string) *NpmResult { } func (u *Updater) StageSuite(source, dir string) *NpmResult { - source = rewriteSkillsSource(source) suiteSource := strings.TrimSuffix(strings.TrimRight(source, "/"), "/regular") + "/isolated" + suiteSource = rewriteSkillsSource(suiteSource) return u.runSkillsCommandInDir(dir, "-y", "skills", "add", suiteSource, "-s", "lark-suite", "-y") } diff --git a/internal/selfupdate/updater_test.go b/internal/selfupdate/updater_test.go index 34ccf8324b..dcb466ad97 100644 --- a/internal/selfupdate/updater_test.go +++ b/internal/selfupdate/updater_test.go @@ -238,6 +238,9 @@ func TestSkillsCommandsUseExpectedArgs(t *testing.T) { t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) if tt.rewrite { testurlrewrite.Register(t, func(rawURL string) string { + if tt.name == "stage suite with rewritten source" && rawURL != "https://open.feishu.cn/lark-cli/skills/isolated" { + t.Fatalf("RewriteURL() input = %q, want final suite source", rawURL) + } return strings.Replace(rawURL, "https://open.feishu.cn", "http://mirror.example.test", 1) }) } diff --git a/internal/transport/extension.go b/internal/transport/extension.go index c64f5b6863..9234dd4210 100644 --- a/internal/transport/extension.go +++ b/internal/transport/extension.go @@ -47,19 +47,26 @@ func (e *resolvedExtension) wrap(base http.RoundTripper, class exttransport.Requ return base } interceptor := e.interceptor + rewriter := e.rewriter if enforceScope && interceptor != nil { if scoped, ok := e.provider.(exttransport.ScopedProvider); ok && !scoped.SupportsRequestClass(class) { interceptor = nil } } - if interceptor == nil && e.rewriter == nil { + // Automatic network rewriting is limited to resolver-owned platform URLs. + // CLI-owned external URLs are rewritten explicitly where they are built, so + // user-provided and pre-signed external requests remain verbatim. + if class != exttransport.RequestClassPlatform { + rewriter = nil + } + if interceptor == nil && rewriter == nil { return base } return &ExtensionMiddleware{ Base: base, Ext: interceptor, ExtName: e.provider.Name(), - rewriter: e.rewriter, + rewriter: rewriter, } } @@ -138,17 +145,18 @@ func (m *ExtensionMiddleware) RoundTrip(req *http.Request) (*http.Response, erro return resp, err } -// WrapWithExtension wraps base with the currently registered transport -// extension. With no registered provider, base is returned unchanged. +// WrapWithExtension wraps base with the currently registered request +// interceptor. Callers that need automatic platform URL rewriting use +// WrapWithExtensionForClass with RequestClassPlatform. func WrapWithExtension(base http.RoundTripper) http.RoundTripper { return resolveExtension().wrap(base, "", false) } -// WrapWithExtensionForClass applies URL rewriting and wraps base with the -// interceptor when the registered provider supports class. ScopedProvider only -// limits the interceptor; URL rewriting remains available for every class. -// Providers without ScopedProvider keep their historical all-request -// interceptor behavior. +// WrapWithExtensionForClass applies URL rewriting to resolver-owned platform +// requests and wraps base with the interceptor when the registered provider +// supports class. External URLs are left unchanged here; fixed CLI-owned +// external URLs are rewritten at their construction sites. Providers without +// ScopedProvider keep their historical all-request interceptor behavior. func WrapWithExtensionForClass(base http.RoundTripper, class exttransport.RequestClass) http.RoundTripper { return resolveExtension().wrap(base, class, true) } diff --git a/internal/transport/extension_test.go b/internal/transport/extension_test.go index dbc9fd1027..51ee8475df 100644 --- a/internal/transport/extension_test.go +++ b/internal/transport/extension_test.go @@ -242,14 +242,14 @@ func TestHTTPPolicyRouterInterceptorObservesRewrittenURL(t *testing.T) { return noContentResponse(req), nil }) req := httptest.NewRequest(http.MethodGet, "https://source.example.test/path", nil) - roundTripForTest(t, WrapWithExtension(base), req) + roundTripForTest(t, WrapWithExtensionForClass(base, exttransport.RequestClassPlatform), req) if interceptor.url != "https://mirror.example.test/path" { t.Fatalf("interceptor URL = %q, want rewritten URL", interceptor.url) } } -func TestHTTPPolicyRouterClassifiesOriginalURLsAndScopesOnlyInterceptor(t *testing.T) { +func TestHTTPPolicyRouterRewritesPlatformButPreservesExternalURL(t *testing.T) { interceptor := &testHeaderInterceptor{} registerTestProvider(t, rewriteTestProvider{ testProvider: testProvider{interceptor: interceptor}, @@ -287,7 +287,7 @@ func TestHTTPPolicyRouterClassifiesOriginalURLsAndScopesOnlyInterceptor(t *testi if want := (receivedRequest{url: "https://open.mirror.test/open-apis/test", header: "routed"}); platform != want { t.Fatalf("platform request = %#v, want %#v", platform, want) } - if want := (receivedRequest{url: "https://external.mirror.test/file"}); external != want { + if want := (receivedRequest{url: "https://external.example.test/file"}); external != want { t.Fatalf("external request = %#v, want %#v", external, want) } if interceptor.calls != 1 { @@ -306,7 +306,7 @@ func TestHTTPPolicyRouterRejectsUnparsableRewriteBeforeBase(t *testing.T) { return nil, nil }) router := NewHTTPPolicyRouter(base, base) - req := httptest.NewRequest(http.MethodGet, "https://example.test/path", nil) + req := httptest.NewRequest(http.MethodGet, "https://open.feishu.cn/path", nil) resp, err := router.RoundTrip(req) if resp != nil { t.Fatalf("response = %v, want nil", resp) diff --git a/internal/update/update.go b/internal/update/update.go index c5c2aec600..2d0b8bef2f 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -18,6 +18,7 @@ import ( "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/transport" + "github.com/larksuite/cli/internal/urlrewrite" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/internal/vfs" ) @@ -200,7 +201,7 @@ type npmLatestResponse struct { } func fetchLatestVersion() (string, error) { - resp, err := httpClient().Get(registryURL) + resp, err := httpClient().Get(urlrewrite.Rewrite(registryURL)) if err != nil { return "", err } diff --git a/internal/update/update_test.go b/internal/update/update_test.go index bda89e1a22..5750840726 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -6,11 +6,13 @@ package update import ( "context" "encoding/json" + "io" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" + "strings" "testing" "time" @@ -24,6 +26,7 @@ func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { re type updateExternalProvider struct { interceptor exttransport.Interceptor + rewriter exttransport.URLRewriter } func (p updateExternalProvider) Name() string { return "update-external-test" } @@ -32,6 +35,10 @@ func (p updateExternalProvider) ResolveInterceptor(context.Context) exttransport return p.interceptor } +func (p updateExternalProvider) ResolveURLRewriter(context.Context) exttransport.URLRewriter { + return p.rewriter +} + func (updateExternalProvider) SupportsRequestClass(class exttransport.RequestClass) bool { return class == exttransport.RequestClassExternal } @@ -40,6 +47,10 @@ type updateExternalInterceptor struct { calls int } +type updateRewriteFunc func(string) string + +func (f updateRewriteFunc) RewriteURL(rawURL string) string { return f(rawURL) } + func (i *updateExternalInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) { i.calls++ req.Header.Set("X-External-Route", "1") @@ -269,7 +280,7 @@ func TestRefreshCache(t *testing.T) { RefreshCache("1.0.0") } -func TestHTTPClientUsesExternalRequestClass(t *testing.T) { +func TestFetchLatestVersionRewritesRegistryAndUsesExternalClass(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) t.Setenv("LARK_CLI_NO_PROXY", "") previousClient := DefaultClient @@ -278,35 +289,38 @@ func TestHTTPClientUsesExternalRequestClass(t *testing.T) { previousProvider := exttransport.GetProvider() interceptor := &updateExternalInterceptor{} - exttransport.Register(updateExternalProvider{interceptor: interceptor}) + exttransport.Register(updateExternalProvider{ + interceptor: interceptor, + rewriter: updateRewriteFunc(func(rawURL string) string { + return strings.Replace(rawURL, "registry.npmjs.org", "registry.example.test", 1) + }), + }) t.Cleanup(func() { exttransport.Register(previousProvider) }) previousTransport := http.DefaultTransport - var receivedHeader string + var receivedHeader, receivedURL string http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) { receivedHeader = req.Header.Get("X-External-Route") + receivedURL = req.URL.String() return &http.Response{ - StatusCode: http.StatusNoContent, + StatusCode: http.StatusOK, Header: make(http.Header), - Body: http.NoBody, + Body: io.NopCloser(strings.NewReader(`{"version":"1.2.3"}`)), Request: req, }, nil }) t.Cleanup(func() { http.DefaultTransport = previousTransport }) - req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/npm/latest", nil) - if err != nil { + if _, err := fetchLatestVersion(); err != nil { t.Fatal(err) } - resp, err := httpClient().Do(req) - if err != nil { - t.Fatal(err) - } - resp.Body.Close() if interceptor.calls != 1 || receivedHeader != "1" { t.Fatalf("external route = calls %d, header %q; want 1, %q", interceptor.calls, receivedHeader, "1") } + if receivedURL != "https://registry.example.test/@larksuite/cli/latest" { + t.Fatalf("registry URL = %q, want rewritten CLI-owned URL", receivedURL) + } } func TestPendingAtomicAccess(t *testing.T) { From 7c92693256b3653fe24e8c05e32133d4c6414060 Mon Sep 17 00:00:00 2001 From: "guokexin.02" <264159873+Tantanz20020918@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:31:05 +0800 Subject: [PATCH 3/8] refactor: simplify URL rewrite plumbing - Drop the one-use urlrewrite.Resolver; ResolveProvider returns the exttransport.URLRewriter (nil when unsupported) and package Rewrite keeps the identity fallback. - ExtensionMiddleware holds the rewriter directly; resolve interceptor and rewriter once with a single context; wrap invalid-rewrite errors with the extension name. - Converge rewriting into URL builders (mail preview/icon, apps scaffold registry, resource URL paths, message app links, errclass ConsoleURL) instead of scattering it across formatting call sites. - Remove dead context threading in cmd/update, the extra root-help template layering, and no-op churn in im/wiki/drive tests. - Use an RFC 2606 .test host in the im-markdown rewrite test so the diff-scoped domain guard passes. --- cmd/build.go | 2 +- cmd/root_help.go | 21 +++----- cmd/update/update.go | 7 +-- cmd/update/update_test.go | 2 +- internal/errclass/classify.go | 6 +-- internal/selfupdate/updater.go | 15 ++---- internal/selfupdate/updater_test.go | 3 -- internal/transport/extension.go | 16 +++---- internal/urlrewrite/rewrite.go | 48 ++++++------------- internal/urlrewrite/rewrite_test.go | 23 +++++---- shortcuts/apps/apps_init.go | 11 +++-- shortcuts/common/resource_url.go | 22 ++++----- shortcuts/doc/docs_fetch_im_markdown.go | 1 + shortcuts/doc/docs_fetch_im_markdown_test.go | 7 ++- .../drive_permission_get_setting_test.go | 3 +- shortcuts/im/chat_app_link_test.go | 3 +- shortcuts/im/convert_lib/content_convert.go | 15 +++--- shortcuts/im/im_chat_messages_list.go | 3 +- shortcuts/im/im_messages_mget.go | 3 +- shortcuts/im/im_threads_messages_list.go | 3 +- shortcuts/mail/large_attachment.go | 18 +++++-- shortcuts/wiki/wiki_node_create_test.go | 3 +- 22 files changed, 102 insertions(+), 133 deletions(-) diff --git a/cmd/build.go b/cmd/build.go index 85d3715517..e113945e92 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -374,7 +374,7 @@ func buildInternalWithConfig(ctx context.Context, inv cmdutil.InvocationContext, // mechanically unchanged. var hasConcealedCommands bool runtime.surface, hasConcealedCommands = applyDistributionPresentation(rootCmd, cfg.presentation, denied) - rootCmd.SetUsageTemplate(rewrittenRootUsageTemplate(runtime.surface)) + rootCmd.SetUsageTemplate(renderRootUsageTemplate(runtime.surface)) // Resolve skill assets and canonical references before installing hooks. // A declared customization is a build-integrity boundary: failure must diff --git a/cmd/root_help.go b/cmd/root_help.go index f426d1c7ae..0ce0f8c3a3 100644 --- a/cmd/root_help.go +++ b/cmd/root_help.go @@ -136,32 +136,25 @@ Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}} Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}` // skillsSetupFooter is the root-help pointer at the human one-time skills -// setup. It is emitted only while skills/read remains referenceable. +// setup. It is emitted only while skills/read remains referenceable. The URL +// is CLI-owned presentation text, so it passes through the URL rewrite +// extension each time the template is rendered. const skillsSetupFooter = `{{if not .HasParent}} -Skills setup (one-time, humans): npx skills add larksuite/cli -g -y — https://github.com/larksuite/cli#agent-skills{{end}}` +Skills setup (one-time, humans): npx skills add larksuite/cli -g -y — %s{{end}}` + +const skillsSetupURL = "https://github.com/larksuite/cli#agent-skills" var rootUsageTemplate = renderRootUsageTemplate(nil) func renderRootUsageTemplate(plan *surface.Plan) string { - return renderRootUsageTemplateWithSkillsURL(plan, "https://github.com/larksuite/cli#agent-skills") -} - -func renderRootUsageTemplateWithSkillsURL(plan *surface.Plan, skillsURL string) string { var b strings.Builder b.WriteString(rootUsageTemplatePrefix) b.WriteString(renderRootHelpFragments(rootUsageSynopsis, plan)) b.WriteString(rootUsageTemplateSuffix) if plan.CanReference(surface.CommandSkillsRead) { - b.WriteString(fmt.Sprintf(`{{if not .HasParent}} - -Skills setup (one-time, humans): npx skills add larksuite/cli -g -y — %s{{end}}`, skillsURL)) + fmt.Fprintf(&b, skillsSetupFooter, urlrewrite.Rewrite(skillsSetupURL)) } b.WriteByte('\n') return b.String() } - -func rewrittenRootUsageTemplate(plan *surface.Plan) string { - return renderRootUsageTemplateWithSkillsURL(plan, - urlrewrite.Rewrite("https://github.com/larksuite/cli#agent-skills")) -} diff --git a/cmd/update/update.go b/cmd/update/update.go index 1f5e40018d..3dff4f260a 100644 --- a/cmd/update/update.go +++ b/cmd/update/update.go @@ -4,7 +4,6 @@ package cmdupdate import ( - "context" "fmt" stdio "io" "runtime" @@ -116,7 +115,7 @@ Use --check to only check for updates without installing. The skill name "lark-suite" is reserved for CLI-managed suite layout.`, RunE: func(cmd *cobra.Command, args []string) error { - return updateRunWithContext(cmd.Context(), opts) + return updateRun(opts) }, } cmdutil.DisableAuthCheck(cmd) @@ -130,10 +129,6 @@ The skill name "lark-suite" is reserved for CLI-managed suite layout.`, } func updateRun(opts *UpdateOptions) error { - return updateRunWithContext(nil, opts) -} - -func updateRunWithContext(ctx context.Context, opts *UpdateOptions) error { io := opts.Factory.IOStreams if _, err := skillscheck.ParseLayout(opts.SkillsLayout); err != nil { return reportError(opts, io, "validation", diff --git a/cmd/update/update_test.go b/cmd/update/update_test.go index 78bd51ee2a..0fab1c2145 100644 --- a/cmd/update/update_test.go +++ b/cmd/update/update_test.go @@ -934,7 +934,7 @@ func TestPermissionHint(t *testing.T) { } // Linux + pnpm: EACCES should point at pnpm setup, not npm prefix/sudo. - pnpmHint := permissionHint("EACCES: permission denied, access 'pnpm-home'", "pnpm") + pnpmHint := permissionHint("EACCES: permission denied, access '/Users/x/Library/pnpm'", "pnpm") if !strings.Contains(pnpmHint, "pnpm setup") { t.Errorf("expected pnpm setup hint, got: %s", pnpmHint) } diff --git a/internal/errclass/classify.go b/internal/errclass/classify.go index 3f2165dc9e..0110d264a9 100644 --- a/internal/errclass/classify.go +++ b/internal/errclass/classify.go @@ -569,10 +569,10 @@ func ConsoleURL(brand, appID string, scopes []string) string { // open-platform base URL stays a single source of truth. base := fmt.Sprintf("%s/page/scope-apply?clientID=%s", core.ResolveOpenBaseURL(core.ParseBrand(brand)), url.QueryEscape(appID)) - if len(scopes) == 0 { - return urlrewrite.Rewrite(base) + if len(scopes) > 0 { + base += "&scopes=" + url.QueryEscape(strings.Join(scopes, ",")) } - return urlrewrite.Rewrite(base + "&scopes=" + url.QueryEscape(strings.Join(scopes, ","))) + return urlrewrite.Rewrite(base) } func intFromAny(v any) int { diff --git a/internal/selfupdate/updater.go b/internal/selfupdate/updater.go index 67caaa4845..ca1ab8640f 100644 --- a/internal/selfupdate/updater.go +++ b/internal/selfupdate/updater.go @@ -344,8 +344,7 @@ func (u *Updater) InstallAllSkills(source string) *NpmResult { func (u *Updater) StageSuite(source, dir string) *NpmResult { suiteSource := strings.TrimSuffix(strings.TrimRight(source, "/"), "/regular") + "/isolated" - suiteSource = rewriteSkillsSource(suiteSource) - return u.runSkillsCommandInDir(dir, "-y", "skills", "add", suiteSource, "-s", "lark-suite", "-y") + return u.runSkillsCommandInDir(dir, "-y", "skills", "add", urlrewrite.Rewrite(suiteSource), "-s", "lark-suite", "-y") } func (u *Updater) InstallLocalSuite(path string) *NpmResult { @@ -360,8 +359,7 @@ func (u *Updater) RemoveGlobalSkills(names []string) *NpmResult { } func (u *Updater) runSkillsAdd(source string) *NpmResult { - source = rewriteSkillsSource(source) - return u.runSkillsCommand("-y", "skills", "add", source, "-g", "-y") + return u.runSkillsCommand("-y", "skills", "add", urlrewrite.Rewrite(source), "-g", "-y") } func (u *Updater) runSkillsListGlobal() *NpmResult { @@ -369,19 +367,12 @@ func (u *Updater) runSkillsListGlobal() *NpmResult { } func (u *Updater) runSkillsInstall(source string, nameList []string) *NpmResult { - source = rewriteSkillsSource(source) - args := []string{"-y", "skills", "add", source, "-s"} + args := []string{"-y", "skills", "add", urlrewrite.Rewrite(source), "-s"} args = append(args, nameList...) args = append(args, "-g", "-y") return u.runSkillsCommand(args...) } -// rewriteSkillsSource applies the optional URL rewriter to the CLI-owned -// skills source passed to npx or pnpm. -func rewriteSkillsSource(source string) string { - return urlrewrite.Rewrite(source) -} - // skillsInvocation decides how to launch the `skills` CLI. When the lark-cli // itself was installed via pnpm and pnpm is available, it uses `pnpm dlx` so // pnpm-only environments (pnpm's standalone installer bundles Node without diff --git a/internal/selfupdate/updater_test.go b/internal/selfupdate/updater_test.go index dcb466ad97..34ccf8324b 100644 --- a/internal/selfupdate/updater_test.go +++ b/internal/selfupdate/updater_test.go @@ -238,9 +238,6 @@ func TestSkillsCommandsUseExpectedArgs(t *testing.T) { t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) if tt.rewrite { testurlrewrite.Register(t, func(rawURL string) string { - if tt.name == "stage suite with rewritten source" && rawURL != "https://open.feishu.cn/lark-cli/skills/isolated" { - t.Fatalf("RewriteURL() input = %q, want final suite source", rawURL) - } return strings.Replace(rawURL, "https://open.feishu.cn", "http://mirror.example.test", 1) }) } diff --git a/internal/transport/extension.go b/internal/transport/extension.go index 9234dd4210..50508a68d2 100644 --- a/internal/transport/extension.go +++ b/internal/transport/extension.go @@ -5,6 +5,7 @@ package transport import ( "context" + "fmt" "net/http" "net/url" @@ -17,7 +18,7 @@ var _ RoundTripperDecorator = (*ExtensionMiddleware)(nil) type resolvedExtension struct { provider exttransport.Provider interceptor exttransport.Interceptor - rewriter *urlrewrite.Resolver + rewriter exttransport.URLRewriter } func resolveExtension() *resolvedExtension { @@ -26,12 +27,11 @@ func resolveExtension() *resolvedExtension { return nil } + ctx := context.Background() extension := &resolvedExtension{ provider: p, - interceptor: p.ResolveInterceptor(context.Background()), - } - if _, ok := p.(exttransport.URLRewriterProvider); ok { - extension.rewriter = urlrewrite.ResolveProvider(context.Background(), p) + interceptor: p.ResolveInterceptor(ctx), + rewriter: urlrewrite.ResolveProvider(ctx, p), } if extension.interceptor == nil && extension.rewriter == nil { return nil @@ -83,7 +83,7 @@ type ExtensionMiddleware struct { Base http.RoundTripper Ext exttransport.Interceptor ExtName string - rewriter *urlrewrite.Resolver + rewriter exttransport.URLRewriter } // BaseRoundTripper returns the wrapped built-in transport chain. @@ -108,11 +108,11 @@ func (m *ExtensionMiddleware) RoundTrip(req *http.Request) (*http.Response, erro origCtx := req.Context() req = req.Clone(origCtx) if m.rewriter != nil { - rewritten := m.rewriter.Rewrite(req.URL.String()) + rewritten := m.rewriter.RewriteURL(req.URL.String()) if rewritten != req.URL.String() { rewrittenURL, err := url.Parse(rewritten) if err != nil { - return nil, err + return nil, fmt.Errorf("extension %q rewrote request URL to an invalid value: %w", m.ExtName, err) } req.URL = rewrittenURL req.Host = rewrittenURL.Host diff --git a/internal/urlrewrite/rewrite.go b/internal/urlrewrite/rewrite.go index 2bac58d95a..30a3315ff5 100644 --- a/internal/urlrewrite/rewrite.go +++ b/internal/urlrewrite/rewrite.go @@ -10,45 +10,27 @@ import ( exttransport "github.com/larksuite/cli/extension/transport" ) -// Resolver holds the URL rewriter resolved for one caller. -// -// A nil rewriter is an identity resolver. Resolve once when a caller needs to -// apply the same extension to multiple URLs. -type Resolver struct { - rewriter exttransport.URLRewriter -} - -// Resolve resolves the URL rewriter from the registered transport provider. -// Providers that do not implement URLRewriterProvider, and providers that -// return a nil rewriter, produce an identity resolver. -func Resolve(ctx context.Context) *Resolver { - return ResolveProvider(ctx, exttransport.GetProvider()) -} - -// ResolveProvider resolves the URL rewriter from p. Callers that have already -// selected a provider should use this function so related extension hooks use -// the same provider instance. -func ResolveProvider(ctx context.Context, provider exttransport.Provider) *Resolver { +// ResolveProvider resolves the URL rewriter from provider. Providers that do +// not implement URLRewriterProvider, and providers that return a nil rewriter, +// resolve to nil. Callers that have already selected a provider should use +// this function so related extension hooks use the same provider instance. +func ResolveProvider(ctx context.Context, provider exttransport.Provider) exttransport.URLRewriter { p, ok := provider.(exttransport.URLRewriterProvider) if !ok { - return &Resolver{} + return nil } - return &Resolver{rewriter: p.ResolveURLRewriter(ctx)} + return p.ResolveURLRewriter(ctx) } -// Rewrite resolves the registered URL rewriter with a background context and -// applies it to rawURL. Rewriting is a synchronous in-process string mapping; -// callers that already captured a provider can use ResolveProvider instead. +// Rewrite applies the registered URL rewriter to rawURL, returning rawURL +// unchanged when no rewriter is registered. Rewriting is a synchronous +// in-process string mapping; the extension is trusted in-process code and +// owns the returned value, so URL-consuming call sites apply their existing +// parsing and transport behavior. func Rewrite(rawURL string) string { - return Resolve(context.Background()).Rewrite(rawURL) -} - -// Rewrite applies the resolved URL rewriter to rawURL. The extension is trusted -// in-process code and owns the returned value; URL-consuming call sites apply -// their existing parsing and transport behavior. -func (r *Resolver) Rewrite(rawURL string) string { - if r == nil || r.rewriter == nil { + rewriter := ResolveProvider(context.Background(), exttransport.GetProvider()) + if rewriter == nil { return rawURL } - return r.rewriter.RewriteURL(rawURL) + return rewriter.RewriteURL(rawURL) } diff --git a/internal/urlrewrite/rewrite_test.go b/internal/urlrewrite/rewrite_test.go index f6ba3cb5cd..b1820ac60b 100644 --- a/internal/urlrewrite/rewrite_test.go +++ b/internal/urlrewrite/rewrite_test.go @@ -8,6 +8,7 @@ import ( "testing" exttransport "github.com/larksuite/cli/extension/transport" + testurlrewrite "github.com/larksuite/cli/internal/testutil/urlrewrite" ) type testProvider struct { @@ -23,26 +24,30 @@ type rewriteFunc func(string) string func (f rewriteFunc) RewriteURL(rawURL string) string { return f(rawURL) } func TestResolveProvider(t *testing.T) { - const raw = "https://example.test/a%2Fb?x=1+2" for _, provider := range []exttransport.Provider{nil, testProvider{}} { - if got := ResolveProvider(context.Background(), provider).Rewrite(raw); got != raw { - t.Fatalf("identity Rewrite() = %q, want %q", got, raw) + if got := ResolveProvider(context.Background(), provider); got != nil { + t.Fatalf("ResolveProvider(%T) = %v, want nil", provider, got) } } - provider := testProvider{rewriter: rewriteFunc(func(string) string { return "/rewritten" })} - if got := ResolveProvider(context.Background(), provider).Rewrite(raw); got != "/rewritten" { - t.Fatalf("Rewrite() = %q, want extension value", got) + got := ResolveProvider(context.Background(), testProvider{rewriter: rewriteFunc(func(string) string { return "/rewritten" })}) + if got == nil || got.RewriteURL("https://example.test/x") != "/rewritten" { + t.Fatalf("ResolveProvider() = %v, want the provider's rewriter", got) } } func TestRewriteUsesRegisteredProvider(t *testing.T) { const rewritten = "/extension-owned/value" - previous := exttransport.GetProvider() - exttransport.Register(testProvider{rewriter: rewriteFunc(func(string) string { return rewritten })}) - t.Cleanup(func() { exttransport.Register(previous) }) + testurlrewrite.Register(t, func(string) string { return rewritten }) if got := Rewrite("https://source.example.test/path"); got != rewritten { t.Fatalf("Rewrite() = %q, want %q", got, rewritten) } } + +func TestRewriteWithoutProviderIsIdentity(t *testing.T) { + const raw = "https://example.test/a%2Fb?x=1+2" + if got := Rewrite(raw); got != raw { + t.Fatalf("Rewrite() = %q, want %q", got, raw) + } +} diff --git a/shortcuts/apps/apps_init.go b/shortcuts/apps/apps_init.go index d412748ef3..c990a95c1c 100644 --- a/shortcuts/apps/apps_init.go +++ b/shortcuts/apps/apps_init.go @@ -409,18 +409,20 @@ func isEmptyRepo(ctx context.Context, dir string) (bool, error) { // Empty repo -> `app init`; non-empty -> `app sync` + meta app_id patch + // conditional `skills sync`. Returns "init" or "upgrade". func runScaffold(ctx context.Context, dir, appID, appType, sourcePath string) (string, error) { - registry := urlrewrite.Rewrite(npmRegistry) empty, err := isEmptyRepo(ctx, dir) if err != nil { return "", err } if empty { - args := scaffoldInitArgsWithRegistry(registry, appType, appID, sourcePath) + args := scaffoldInitArgs(appType, appID, sourcePath) if _, stderr, err := initRunner.Run(ctx, dir, "npx", args...); err != nil { return "", appsExternalToolError(err, "npx app init failed: %s", gitErr(stderr, err)) } return scaffoldKindInit, nil } + // The npm registry is a CLI-owned URL handed to the child process, so it + // passes through the URL rewrite extension once for both npx invocations. + registry := urlrewrite.Rewrite(npmRegistry) policy := policyForAppType(appType) if !policy.skipAppSync { if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", "--registry", registry, miaodaCLIPkg, "app", "sync"); err != nil { @@ -446,9 +448,10 @@ func runScaffold(ctx context.Context, dir, appID, appType, sourcePath string) (s // install; others run it as usual. // appType is forwarded verbatim (including "frontend") — the CLI does not // translate the app type; mapping the app type to a concrete tech stack is the -// downstream tool's responsibility. +// downstream tool's responsibility. The registry is a CLI-owned URL handed to +// npx, so it passes through the URL rewrite extension. func scaffoldInitArgs(appType, appID, sourcePath string) []string { - return scaffoldInitArgsWithRegistry(npmRegistry, appType, appID, sourcePath) + return scaffoldInitArgsWithRegistry(urlrewrite.Rewrite(npmRegistry), appType, appID, sourcePath) } func scaffoldInitArgsWithRegistry(registry, appType, appID, sourcePath string) []string { diff --git a/shortcuts/common/resource_url.go b/shortcuts/common/resource_url.go index 6a3c9f16bb..0c68b775e2 100644 --- a/shortcuts/common/resource_url.go +++ b/shortcuts/common/resource_url.go @@ -34,30 +34,30 @@ func BuildResourceURL(brand core.LarkBrand, kind, token string) string { host = "https://www.larksuite.com" } - var resourceURL string + var path string switch strings.ToLower(strings.TrimSpace(kind)) { case "docx": - resourceURL = host + "/docx/" + token + path = "/docx/" case "doc": - resourceURL = host + "/doc/" + token + path = "/doc/" case "sheet": - resourceURL = host + "/sheets/" + token + path = "/sheets/" case "bitable": - resourceURL = host + "/base/" + token + path = "/base/" case "wiki": - resourceURL = host + "/wiki/" + token + path = "/wiki/" case "file": - resourceURL = host + "/file/" + token + path = "/file/" case "folder": - resourceURL = host + "/drive/folder/" + token + path = "/drive/folder/" case "mindnote": - resourceURL = host + "/mindnote/" + token + path = "/mindnote/" case "slides": - resourceURL = host + "/slides/" + token + path = "/slides/" default: return "" } - return urlrewrite.Rewrite(resourceURL) + return urlrewrite.Rewrite(host + path + token) } // ResourceRef holds the parsed type and token from a Lark resource URL. diff --git a/shortcuts/doc/docs_fetch_im_markdown.go b/shortcuts/doc/docs_fetch_im_markdown.go index 8ca47cd280..a4e87d3c3a 100644 --- a/shortcuts/doc/docs_fetch_im_markdown.go +++ b/shortcuts/doc/docs_fetch_im_markdown.go @@ -119,6 +119,7 @@ func newIMMarkdownContext(docInput string) imMarkdownContext { base := "https://larkoffice.com" raw := strings.TrimSpace(docInput) if extracted, ok := imMarkdownBaseURLFromInput(raw); ok { + // The tenant host comes from user input and stays verbatim. base = extracted } else { base = urlrewrite.Rewrite(base) diff --git a/shortcuts/doc/docs_fetch_im_markdown_test.go b/shortcuts/doc/docs_fetch_im_markdown_test.go index 4afd2ecacd..77f401166c 100644 --- a/shortcuts/doc/docs_fetch_im_markdown_test.go +++ b/shortcuts/doc/docs_fetch_im_markdown_test.go @@ -75,12 +75,12 @@ func TestApplyFetchIMMarkdown(t *testing.T) { func TestNewIMMarkdownContextRewritesFallbackURL(t *testing.T) { testurlrewrite.Register(t, func(raw string) string { if raw == "https://larkoffice.com" { - return "https://tenant.example.com/base" + return "https://tenant.example.test/base" } return raw }) - if got := newIMMarkdownContext("doc_token").baseURL; got != "https://tenant.example.com/base" { + if got := newIMMarkdownContext("doc_token").baseURL; got != "https://tenant.example.test/base" { t.Fatalf("baseURL = %q", got) } } @@ -1091,8 +1091,7 @@ func TestNewIMMarkdownContextExtractsBaseURL(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - imCtx := newIMMarkdownContext(tt.input) - if got := imCtx.baseURL; got != tt.want { + if got := newIMMarkdownContext(tt.input).baseURL; got != tt.want { t.Fatalf("baseURL = %q, want %q", got, tt.want) } }) diff --git a/shortcuts/drive/drive_permission_get_setting_test.go b/shortcuts/drive/drive_permission_get_setting_test.go index 8a01608c4a..80fc71a223 100644 --- a/shortcuts/drive/drive_permission_get_setting_test.go +++ b/shortcuts/drive/drive_permission_get_setting_test.go @@ -192,8 +192,7 @@ func TestDrivePermissionGetSettingResourceURLUsesConfiguredBrand(t *testing.T) { if err != nil { t.Fatalf("read spec: %v", err) } - got := spec.url(runtime) - if want := "https://www.larksuite.com/page/appMetaTok"; got != want { + if got, want := spec.url(runtime), "https://www.larksuite.com/page/appMetaTok"; got != want { t.Fatalf("resource URL = %q, want %q", got, want) } } diff --git a/shortcuts/im/chat_app_link_test.go b/shortcuts/im/chat_app_link_test.go index 04c25f47be..307219ea14 100644 --- a/shortcuts/im/chat_app_link_test.go +++ b/shortcuts/im/chat_app_link_test.go @@ -47,8 +47,7 @@ func TestAssembleChatAppLink(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := assembleChatAppLink(tt.chatID, tt.brand) - if got != tt.want { + if got := assembleChatAppLink(tt.chatID, tt.brand); got != tt.want { t.Fatalf("assembleChatAppLink() = %q, want %q", got, tt.want) } }) diff --git a/shortcuts/im/convert_lib/content_convert.go b/shortcuts/im/convert_lib/content_convert.go index 5395167c25..992397bebf 100644 --- a/shortcuts/im/convert_lib/content_convert.go +++ b/shortcuts/im/convert_lib/content_convert.go @@ -275,8 +275,9 @@ func assembleMessageAppLink(m map[string]interface{}, brand core.LarkBrand) stri // Thread app link requires both thread_id and chat_id. // Emit both underscore-less (openthreadid/openchatid) and snake_case (open_thread_id/open_chat_id) // query keys so PC and mobile clients can both resolve the link. + var u *url.URL if threadID != "" && chatID != "" && okThreadPos { - u := &url.URL{Scheme: "https", Host: domain, Path: "/client/thread/open"} + u = &url.URL{Scheme: "https", Host: domain, Path: "/client/thread/open"} q := url.Values{} q.Set("openthreadid", threadID) q.Set("openchatid", chatID) @@ -284,17 +285,17 @@ func assembleMessageAppLink(m map[string]interface{}, brand core.LarkBrand) stri q.Set("open_chat_id", chatID) q.Set("thread_position", threadPos) u.RawQuery = q.Encode() - return urlrewrite.Rewrite(u.String()) - } - if chatID != "" && okMsgPos { - u := &url.URL{Scheme: "https", Host: domain, Path: "/client/chat/open"} + } else if chatID != "" && okMsgPos { + u = &url.URL{Scheme: "https", Host: domain, Path: "/client/chat/open"} q := url.Values{} q.Set("openChatId", chatID) q.Set("position", msgPos) u.RawQuery = q.Encode() - return urlrewrite.Rewrite(u.String()) } - return "" + if u == nil { + return "" + } + return urlrewrite.Rewrite(u.String()) } func normalizeMessagePosition(v interface{}) (string, bool) { diff --git a/shortcuts/im/im_chat_messages_list.go b/shortcuts/im/im_chat_messages_list.go index 8aa1d49f25..8e3114216f 100644 --- a/shortcuts/im/im_chat_messages_list.go +++ b/shortcuts/im/im_chat_messages_list.go @@ -160,8 +160,7 @@ var ImChatMessageList = common.Shortcut{ downloadResources := runtime.Bool("download-resources") messages := make([]map[string]interface{}, 0, len(rawItems)) for _, m := range result.items { - message := convertlib.FormatMessageItemWithMergePrefetchOpts(m, runtime, nameCache, mergePrefetch, downloadResources) - messages = append(messages, message) + messages = append(messages, convertlib.FormatMessageItemWithMergePrefetchOpts(m, runtime, nameCache, mergePrefetch, downloadResources)) } // Enrich: resolve sender names for outer messages (reuses cache from merge_forward) diff --git a/shortcuts/im/im_messages_mget.go b/shortcuts/im/im_messages_mget.go index cf0ac82d49..d13b487852 100644 --- a/shortcuts/im/im_messages_mget.go +++ b/shortcuts/im/im_messages_mget.go @@ -83,8 +83,7 @@ var ImMessagesMGet = common.Shortcut{ messages := make([]map[string]interface{}, 0, len(rawItems)) for _, item := range rawItems { m, _ := item.(map[string]interface{}) - message := convertlib.FormatMessageItemWithMergePrefetchOpts(m, runtime, nameCache, mergePrefetch, downloadResources) - messages = append(messages, message) + messages = append(messages, convertlib.FormatMessageItemWithMergePrefetchOpts(m, runtime, nameCache, mergePrefetch, downloadResources)) } convertlib.ResolveSenderNames(runtime, messages, nameCache) diff --git a/shortcuts/im/im_threads_messages_list.go b/shortcuts/im/im_threads_messages_list.go index 46d86869b1..0c24ad2e43 100644 --- a/shortcuts/im/im_threads_messages_list.go +++ b/shortcuts/im/im_threads_messages_list.go @@ -133,8 +133,7 @@ var ImThreadsMessagesList = common.Shortcut{ downloadResources := runtime.Bool("download-resources") messages := make([]map[string]interface{}, 0, len(rawItems)) for _, m := range result.items { - message := convertlib.FormatMessageItemWithMergePrefetchOpts(m, runtime, nameCache, mergePrefetch, downloadResources) - messages = append(messages, message) + messages = append(messages, convertlib.FormatMessageItemWithMergePrefetchOpts(m, runtime, nameCache, mergePrefetch, downloadResources)) } // Enrich: resolve sender names for outer messages (reuses cache from merge_forward) diff --git a/shortcuts/mail/large_attachment.go b/shortcuts/mail/large_attachment.go index 0df0a8d6b4..0388e33af4 100644 --- a/shortcuts/mail/large_attachment.go +++ b/shortcuts/mail/large_attachment.go @@ -197,13 +197,21 @@ func uploadLargeAttachments(ctx context.Context, runtime *common.RuntimeContext, // buildLargeAttachmentPreviewURL builds the download/preview URL for a large // attachment token. The domain is derived from the CLI's configured endpoint -// (e.g. open.feishu.cn → www.feishu.cn). +// (e.g. open.feishu.cn → www.feishu.cn). The URL is CLI-owned presentation +// text, so it passes through the URL rewrite extension here rather than at +// each card-formatting call site. func buildLargeAttachmentPreviewURL(brand core.LarkBrand, fileToken string) string { ep := core.ResolveEndpoints(brand) host := strings.TrimPrefix(ep.Open, "https://") host = strings.TrimPrefix(host, "http://") mainDomain := strings.TrimPrefix(host, "open.") - return "https://www." + mainDomain + "/mail/page/attachment?token=" + url.QueryEscape(fileToken) + return urlrewrite.Rewrite("https://www." + mainDomain + "/mail/page/attachment?token=" + url.QueryEscape(fileToken)) +} + +// largeAttachmentIconURL builds the CLI-owned CDN icon URL for an attachment, +// applying the URL rewrite extension. +func largeAttachmentIconURL(iconCDN, filename string) string { + return urlrewrite.Rewrite(iconCDN + fileTypeIcon(filename)) } // buildLargeAttachmentHTML generates the HTML block for large attachments, @@ -272,10 +280,10 @@ func buildLargeAttachmentItems(brand core.LarkBrand, lang string, results []larg var items strings.Builder for _, att := range results { fmt.Fprintf(&items, largeAttItemTpl, - htmlEscape(urlrewrite.Rewrite(iconCDN+fileTypeIcon(att.FileName))), + htmlEscape(largeAttachmentIconURL(iconCDN, att.FileName)), htmlEscape(att.FileName), htmlEscape(common.FormatSize(att.FileSize)), - htmlEscape(urlrewrite.Rewrite(buildLargeAttachmentPreviewURL(brand, att.FileToken))), + htmlEscape(buildLargeAttachmentPreviewURL(brand, att.FileToken)), htmlEscape(att.FileToken), downloadText, ) @@ -321,7 +329,7 @@ func buildLargeAttachmentPlainText(brand core.LarkBrand, lang string, results [] sb.WriteString("\n") sb.WriteString(common.FormatSize(att.FileSize)) sb.WriteString("\n") - sb.WriteString(downloadText + ": " + urlrewrite.Rewrite(buildLargeAttachmentPreviewURL(brand, att.FileToken))) + sb.WriteString(downloadText + ": " + buildLargeAttachmentPreviewURL(brand, att.FileToken)) if i < len(results)-1 { sb.WriteString("\n\n") } else { diff --git a/shortcuts/wiki/wiki_node_create_test.go b/shortcuts/wiki/wiki_node_create_test.go index 66a5f6be1d..d4be465877 100644 --- a/shortcuts/wiki/wiki_node_create_test.go +++ b/shortcuts/wiki/wiki_node_create_test.go @@ -896,8 +896,7 @@ func TestWikiNodeURL(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - got := wikiNodeURL(core.BrandFeishu, tc.node) - if got != tc.want { + if got := wikiNodeURL(core.BrandFeishu, tc.node); got != tc.want { t.Fatalf("wikiNodeURL() = %q, want %q", got, tc.want) } }) From 29b3d835048982c04e4d74d2209d934ad1f26405 Mon Sep 17 00:00:00 2001 From: "guokexin.02" <264159873+Tantanz20020918@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:02:25 +0800 Subject: [PATCH 4/8] fix: handle rewritten URLs across edge paths --- cmd/build.go | 2 +- cmd/command_sets_test.go | 10 ++++++++++ internal/transport/default_client.go | 3 ++- internal/transport/extension_test.go | 19 +++++++++++++++++++ 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/cmd/build.go b/cmd/build.go index e113945e92..cf8c7c1f1d 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -269,7 +269,7 @@ func buildInternalWithConfig(ctx context.Context, inv cmdutil.InvocationContext, // Root-only usage template (curated Usage synopsis + skills footer); see // rootUsageTemplate. - rootCmd.SetUsageTemplate(rootUsageTemplate) + rootCmd.SetUsageTemplate(renderRootUsageTemplate(nil)) // Framework-generated skill pointers read this build's final content and // exact command surface lazily. A second Build therefore cannot rewrite diff --git a/cmd/command_sets_test.go b/cmd/command_sets_test.go index 29f9df7176..0fc051c072 100644 --- a/cmd/command_sets_test.go +++ b/cmd/command_sets_test.go @@ -15,6 +15,7 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/command" "github.com/larksuite/cli/extension/platform" + testurlrewrite "github.com/larksuite/cli/internal/testutil/urlrewrite" ) type businessArgs struct { @@ -62,6 +63,12 @@ func TestWithCommandSetsInIsolatedProcesses(t *testing.T) { func TestFailedBuildDoesNotAffectNextBuild(t *testing.T) { tmpHome(t) + testurlrewrite.Register(t, func(rawURL string) string { + if rawURL == skillsSetupURL { + return "https://mirror.example/skills-help" + } + return rawURL + }) platform.ResetForTesting() t.Cleanup(platform.ResetForTesting) platform.Register(&failingPlugin{ @@ -80,6 +87,9 @@ func TestFailedBuildDoesNotAffectNextBuild(t *testing.T) { if findCommand(failed, "im +business-failed-build") == nil || failed.PersistentPreRunE == nil { t.Fatal("failed build did not reach the post-mount plugin guard") } + if !strings.Contains(failed.UsageTemplate(), "https://mirror.example/skills-help") { + t.Fatal("failed build retained the package-initialized help URL") + } platform.ResetForTesting() clean := Build(context.Background(), buildInvocationForTest(t), WithoutPlugins(), WithoutStrictMode(), WithoutServiceCommands()) diff --git a/internal/transport/default_client.go b/internal/transport/default_client.go index d7a02e04f6..c6ace957e8 100644 --- a/internal/transport/default_client.go +++ b/internal/transport/default_client.go @@ -128,7 +128,8 @@ func (t *sameOriginRedirectTransport) RoundTrip(req *http.Request) (*http.Respon parseErr, ).WithCause(parseErr) } - if sameOrigin(req.URL, target) { + if sameOrigin(req.URL, target) || + (resp.Request != nil && sameOrigin(resp.Request.URL, target)) { return resp, nil } diff --git a/internal/transport/extension_test.go b/internal/transport/extension_test.go index 51ee8475df..abc9df8d3c 100644 --- a/internal/transport/extension_test.go +++ b/internal/transport/extension_test.go @@ -579,6 +579,25 @@ func TestSDKBootstrapRedirectGuardUsesLogicalURLAfterExtensionRewrite(t *testing } } +func TestSDKBootstrapRedirectGuardAllowsRewrittenOrigin(t *testing.T) { + logical, err := http.NewRequest(http.MethodGet, "https://platform.example/bootstrap", nil) + if err != nil { + t.Fatal(err) + } + effective := logical.Clone(logical.Context()) + effective.URL, err = url.Parse("https://mirror.example/bootstrap") + if err != nil { + t.Fatal(err) + } + guard := &sameOriginRedirectTransport{base: roundTripFunc(func(*http.Request) (*http.Response, error) { + return redirectResponse(effective, http.StatusTemporaryRedirect, "https://mirror.example/next"), nil + })} + + if _, err := guard.RoundTrip(logical); err != nil { + t.Fatalf("RoundTrip() error = %v, want rewritten-origin redirect allowed", err) + } +} + func TestSDKBootstrapRedirectGuardChecksLocationAfterExtensionPostHook(t *testing.T) { var externalCalls atomic.Int32 sidecarURL, err := url.Parse("https://sidecar.example") From a2c570470f780e73a077f94f2f65f08b0adc3528 Mon Sep 17 00:00:00 2001 From: "guokexin.02" <264159873+Tantanz20020918@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:24:02 +0800 Subject: [PATCH 5/8] fix: preserve platform redirects after URL rewriting --- internal/cmdutil/factory_default.go | 37 ++++++++++++++++++-- internal/cmdutil/factory_http_test.go | 49 +++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/internal/cmdutil/factory_default.go b/internal/cmdutil/factory_default.go index 9e396c741a..6a4cb13c98 100644 --- a/internal/cmdutil/factory_default.go +++ b/internal/cmdutil/factory_default.go @@ -19,6 +19,7 @@ import ( "github.com/larksuite/cli/errs" extcred "github.com/larksuite/cli/extension/credential" "github.com/larksuite/cli/extension/fileio" + exttransport "github.com/larksuite/cli/extension/transport" "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" @@ -27,6 +28,7 @@ import ( "github.com/larksuite/cli/internal/riskcontrol" _ "github.com/larksuite/cli/internal/security/contentsafety" // register content safety provider "github.com/larksuite/cli/internal/transport" + "github.com/larksuite/cli/internal/urlrewrite" _ "github.com/larksuite/cli/internal/vfs/localfileio" // register default FileIO provider ) @@ -117,14 +119,21 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error { } original := via[0] previous := via[len(via)-1] - if previous.URL != nil && req.URL != nil && strings.EqualFold(previous.URL.Scheme, "https") && !strings.EqualFold(req.URL.Scheme, "https") { + originalURL, previousURL, targetURL := original.URL, previous.URL, req.URL + platformRedirect := core.IsPlatformEndpointURL(originalURL) + if platformRedirect { + originalURL = effectiveRedirectURL(originalURL) + previousURL = effectiveRedirectURL(previousURL) + targetURL = effectiveRedirectURL(targetURL) + } + if previousURL != nil && targetURL != nil && strings.EqualFold(previousURL.Scheme, "https") && !strings.EqualFold(targetURL.Scheme, "https") { return errs.NewSecurityPolicyError( errs.SubtypeAccessDenied, "redirect from HTTPS to %s is not allowed", req.URL.Scheme, ) } - if !sameRedirectOrigin(previous.URL, req.URL) { + if !sameRedirectOrigin(previousURL, targetURL) { if req.Method != http.MethodGet && req.Method != http.MethodHead { return errs.NewSecurityPolicyError( errs.SubtypeAccessDenied, @@ -142,14 +151,36 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error { // net/http copies initial headers onto every redirect request. Continue // stripping credentials for every hop outside the initial origin, even when // two consecutive redirect targets share an origin. - if !sameRedirectOrigin(original.URL, req.URL) { + if !sameRedirectOrigin(originalURL, targetURL) { req.Header.Del("Authorization") req.Header.Del("X-Lark-MCP-UAT") req.Header.Del("X-Lark-MCP-TAT") + } else if platformRedirect { + // net/http sees the logical platform host and the rewritten host as + // different origins and removes Authorization before CheckRedirect. + // Restore it only after the effective origins have matched. + if values := original.Header.Values("Authorization"); len(values) > 0 { + req.Header.Del("Authorization") + for _, value := range values { + req.Header.Add("Authorization", value) + } + } + *req = *transport.WithRequestClass(req, exttransport.RequestClassPlatform) } return nil } +func effectiveRedirectURL(candidate *url.URL) *url.URL { + if candidate == nil || !core.IsPlatformEndpointURL(candidate) { + return candidate + } + rewritten, err := url.Parse(urlrewrite.Rewrite(candidate.String())) + if err != nil || rewritten.Scheme == "" || rewritten.Host == "" { + return candidate + } + return rewritten +} + func sameRedirectOrigin(left, right *url.URL) bool { if left == nil || right == nil { return false diff --git a/internal/cmdutil/factory_http_test.go b/internal/cmdutil/factory_http_test.go index 88d471b37b..3a2b352444 100644 --- a/internal/cmdutil/factory_http_test.go +++ b/internal/cmdutil/factory_http_test.go @@ -14,9 +14,58 @@ import ( "github.com/larksuite/cli/errs" exttransport "github.com/larksuite/cli/extension/transport" "github.com/larksuite/cli/internal/core" + testurlrewrite "github.com/larksuite/cli/internal/testutil/urlrewrite" internaltransport "github.com/larksuite/cli/internal/transport" ) +func TestSafeRedirectPolicyUsesRewrittenPlatformOrigin(t *testing.T) { + testurlrewrite.Register(t, func(rawURL string) string { + return strings.Replace(rawURL, "https://open.feishu.cn", "https://mirror.example", 1) + }) + + platformCalls := 0 + platform := roundTripFunc(func(req *http.Request) (*http.Response, error) { + platformCalls++ + if req.URL.Host != "mirror.example" { + t.Fatalf("platform request host = %q", req.URL.Host) + } + if req.Header.Get("Authorization") != "Bearer secret" { + t.Fatalf("redirect lost Authorization: %#v", req.Header) + } + if platformCalls == 1 { + return &http.Response{ + StatusCode: http.StatusTemporaryRedirect, + Header: http.Header{"Location": []string{"https://mirror.example/next"}}, + Body: http.NoBody, + Request: req, + }, nil + } + return &http.Response{StatusCode: http.StatusNoContent, Header: make(http.Header), Body: http.NoBody, Request: req}, nil + }) + external := roundTripFunc(func(req *http.Request) (*http.Response, error) { + t.Fatalf("rewritten redirect used external policy for %s", req.URL) + return nil, nil + }) + client := &http.Client{ + Transport: internaltransport.NewHTTPPolicyRouter(platform, external), + CheckRedirect: safeRedirectPolicy, + } + req, err := http.NewRequest(http.MethodPost, "https://open.feishu.cn/start", strings.NewReader("body")) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer secret") + + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if platformCalls != 2 { + t.Fatalf("platform calls = %d, want 2", platformCalls) + } +} + func TestCachedHTTPClientFunc_ReturnsSameInstance(t *testing.T) { isEnabled := false f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"}) From 4b0ae63459e22c68829ec99e3294f8d86ff9e082 Mon Sep 17 00:00:00 2001 From: "guokexin.02" <264159873+Tantanz20020918@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:06:05 +0800 Subject: [PATCH 6/8] fix: guard new static URL rewrites --- cmd/command_sets_test.go | 2 +- cmd/root_help.go | 8 +- lint/README.md | 9 ++ lint/domaincontract/unapproved.go | 8 +- lint/domaincontract/unapproved_repo_test.go | 12 +++ lint/domaincontract/urlrewrite.go | 114 ++++++++++++++++++++ lint/domaincontract/urlrewrite_test.go | 46 ++++++++ 7 files changed, 194 insertions(+), 5 deletions(-) create mode 100644 lint/domaincontract/urlrewrite.go create mode 100644 lint/domaincontract/urlrewrite_test.go diff --git a/cmd/command_sets_test.go b/cmd/command_sets_test.go index 0fc051c072..7aac595556 100644 --- a/cmd/command_sets_test.go +++ b/cmd/command_sets_test.go @@ -64,7 +64,7 @@ func TestWithCommandSetsInIsolatedProcesses(t *testing.T) { func TestFailedBuildDoesNotAffectNextBuild(t *testing.T) { tmpHome(t) testurlrewrite.Register(t, func(rawURL string) string { - if rawURL == skillsSetupURL { + if rawURL == "https://github.com/larksuite/cli#agent-skills" { return "https://mirror.example/skills-help" } return rawURL diff --git a/cmd/root_help.go b/cmd/root_help.go index 0ce0f8c3a3..e2309f1edd 100644 --- a/cmd/root_help.go +++ b/cmd/root_help.go @@ -143,17 +143,19 @@ const skillsSetupFooter = `{{if not .HasParent}} Skills setup (one-time, humans): npx skills add larksuite/cli -g -y — %s{{end}}` -const skillsSetupURL = "https://github.com/larksuite/cli#agent-skills" - var rootUsageTemplate = renderRootUsageTemplate(nil) +func skillsSetupURL() string { + return urlrewrite.Rewrite("https://github.com/larksuite/cli#agent-skills") +} + func renderRootUsageTemplate(plan *surface.Plan) string { var b strings.Builder b.WriteString(rootUsageTemplatePrefix) b.WriteString(renderRootHelpFragments(rootUsageSynopsis, plan)) b.WriteString(rootUsageTemplateSuffix) if plan.CanReference(surface.CommandSkillsRead) { - fmt.Fprintf(&b, skillsSetupFooter, urlrewrite.Rewrite(skillsSetupURL)) + fmt.Fprintf(&b, skillsSetupFooter, skillsSetupURL()) } b.WriteByte('\n') return b.String() diff --git a/lint/README.md b/lint/README.md index 975206c929..61b30a0eb1 100644 --- a/lint/README.md +++ b/lint/README.md @@ -112,6 +112,15 @@ hostnames, and have a current in-scope use. See This is not a general outbound-URL or cross-language data-flow analyzer. It does not inspect non-Go assets or dynamically constructed values. +For added production lines in the CLI runtime (`main.go`, `cmd/`, `internal/`, +and `shortcuts/`), the same scan also requires approved static HTTP(S)/WS(S) URLs to +appear inside `urlrewrite.Rewrite(...)`. This covers literals, constant uses, +and static concatenation while leaving platform network URLs in +`core.ResolveEndpoints` untouched; those are rewritten by the transport layer. +Tests, quality-gate tooling, and the URL rewrite implementation itself are out +of scope. A non-routable identifier such as a protocol namespace may use +`//nolint:urlrewrite ` on the same or immediately preceding line. + To add or change a resolver-owned Feishu/Lark endpoint, edit the resolver rather than hardcoding the host elsewhere. diff --git a/lint/domaincontract/unapproved.go b/lint/domaincontract/unapproved.go index db2badfdf5..16ca269164 100644 --- a/lint/domaincontract/unapproved.go +++ b/lint/domaincontract/unapproved.go @@ -26,6 +26,7 @@ const ( unapprovedDomainRule = "unapproved-domain" unusedDomainRule = "domain-allowlist-unused" incompleteDomainRule = "domain-scan-incomplete" + urlRewriteRule = "url-rewrite-required" ) type typedGoFile struct { @@ -110,7 +111,7 @@ func scanUnapprovedDomains(root string, opts ScanOptions) ([]lintapi.Violation, for _, rel := range goFiles { path := filepath.Join(root, filepath.FromSlash(rel)) parsedFset := token.NewFileSet() - parsedFile, parseErr := parser.ParseFile(parsedFset, path, nil, 0) + parsedFile, parseErr := parser.ParseFile(parsedFset, path, nil, parser.ParseComments) if parseErr != nil { inventoryComplete = false if opts.ChangedFrom == "" { @@ -167,6 +168,11 @@ func scanUnapprovedDomains(root string, opts ScanOptions) ([]lintapi.Violation, if !fixture && !policyOwner { observedPublic[evidence.Host] = true } + if opts.ChangedFrom != "" { + if violation, ok := scan.unrewrittenURLViolation(rel, evidence, added[rel]); ok { + out = append(out, violation) + } + } continue } if _, ok := policy.Fixtures[evidence.Host]; ok && fixture { diff --git a/lint/domaincontract/unapproved_repo_test.go b/lint/domaincontract/unapproved_repo_test.go index 07d8ec4e04..60af047453 100644 --- a/lint/domaincontract/unapproved_repo_test.go +++ b/lint/domaincontract/unapproved_repo_test.go @@ -67,6 +67,18 @@ func scanDomainDiff(t *testing.T, root, base string) []lintapi.Violation { } func TestUnapprovedDomainDiffContract(t *testing.T) { + t.Run("approved static URL requires rewrite", func(t *testing.T) { + root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n") + writeFile(t, root, "cmd/target.go", + "package cmd\n\nvar helpURL = \"https://public.example.com/help\"\n") + commitDomainDiff(t, root, "add static help URL") + + got := violationsForRule(scanDomainDiff(t, root, base), urlRewriteRule) + if len(got) != 1 || filepath.ToSlash(got[0].File) != "cmd/target.go" { + t.Fatalf("violations = %+v, want static URL rejection", got) + } + }) + t.Run("new PR 1975 case", func(t *testing.T) { root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n") writeFile(t, root, "target.go", diff --git a/lint/domaincontract/urlrewrite.go b/lint/domaincontract/urlrewrite.go new file mode 100644 index 0000000000..5d555b6242 --- /dev/null +++ b/lint/domaincontract/urlrewrite.go @@ -0,0 +1,114 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package domaincontract + +import ( + "go/ast" + "path/filepath" + "strconv" + "strings" + + "github.com/larksuite/cli/lint/lintapi" +) + +const urlRewriteImport = "github.com/larksuite/cli/internal/urlrewrite" + +func (s *fileDomainScan) unrewrittenURLViolation(rel string, evidence domainEvidence, added []addedLineRange) (lintapi.Violation, bool) { + if evidence.Kind != "absolute URL" || !urlRewriteRuntimeFile(rel) || + (rel == resolverPath && s.inEndpointResolver(evidence.Expr)) || s.inURLRewriteCall(evidence.Expr) || s.hasURLRewriteExemption(evidence.Expr) { + return lintapi.Violation{}, false + } + start := s.Fset.Position(evidence.Expr.Pos()).Line + end := s.Fset.Position(evidence.Expr.End()).Line + line, ok := firstAddedLineInSpan(added, start, end) + if !ok { + return lintapi.Violation{}, false + } + return lintapi.Violation{ + Rule: urlRewriteRule, + Action: lintapi.ActionReject, + File: rel, + Line: line, + Message: "new static URL must pass through urlrewrite.Rewrite", + Suggestion: "rewrite the complete URL at its use site, or add //nolint:urlrewrite with a reason when it is not a routable URL", + }, true +} + +func urlRewriteRuntimeFile(rel string) bool { + rel = filepath.ToSlash(rel) + if strings.HasSuffix(rel, "_test.go") || strings.Contains(rel, "/testdata/") || + strings.HasPrefix(rel, "internal/qualitygate/") || strings.HasPrefix(rel, "internal/testutil/") || + strings.HasPrefix(rel, "internal/urlrewrite/") { + return false + } + return rel == "main.go" || strings.HasPrefix(rel, "cmd/") || + strings.HasPrefix(rel, "internal/") || strings.HasPrefix(rel, "shortcuts/") +} + +func (s *fileDomainScan) inURLRewriteCall(expr ast.Expr) bool { + aliases := map[string]bool{} + for _, imp := range s.File.Imports { + path, err := strconv.Unquote(imp.Path.Value) + if err != nil || path != urlRewriteImport { + continue + } + name := "urlrewrite" + if imp.Name != nil { + name = imp.Name.Name + } + aliases[name] = true + } + for node := ast.Node(expr); node != nil; node = s.parents[node] { + call, ok := node.(*ast.CallExpr) + if !ok { + continue + } + sel, ok := call.Fun.(*ast.SelectorExpr) + pkg, pkgOK := sel.X.(*ast.Ident) + if ok && pkgOK && aliases[pkg.Name] && sel.Sel.Name == "Rewrite" && !s.atPackageScope(call) { + return true + } + } + return false +} + +func (s *fileDomainScan) atPackageScope(node ast.Node) bool { + for parent := s.parents[node]; parent != nil; parent = s.parents[parent] { + switch parent.(type) { + case *ast.FuncDecl, *ast.FuncLit: + return false + } + if _, ok := parent.(*ast.File); ok { + return true + } + } + return false +} + +func (s *fileDomainScan) inEndpointResolver(expr ast.Expr) bool { + for node := ast.Node(expr); node != nil; node = s.parents[node] { + if fn, ok := node.(*ast.FuncDecl); ok { + return fn.Recv == nil && fn.Name.Name == "ResolveEndpoints" + } + } + return false +} + +func (s *fileDomainScan) hasURLRewriteExemption(expr ast.Expr) bool { + start := s.Fset.Position(expr.Pos()).Line + end := s.Fset.Position(expr.End()).Line + for _, group := range s.File.Comments { + line := s.Fset.Position(group.Pos()).Line + if line != start-1 && (line < start || line > end) { + continue + } + for _, comment := range group.List { + const marker = "nolint:urlrewrite" + if index := strings.Index(comment.Text, marker); index >= 0 && strings.TrimSpace(comment.Text[index+len(marker):]) != "" { + return true + } + } + } + return false +} diff --git a/lint/domaincontract/urlrewrite_test.go b/lint/domaincontract/urlrewrite_test.go new file mode 100644 index 0000000000..72216a5c06 --- /dev/null +++ b/lint/domaincontract/urlrewrite_test.go @@ -0,0 +1,46 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package domaincontract + +import ( + "go/parser" + "go/token" + "testing" +) + +func TestStaticURLRewriteGuard(t *testing.T) { + tests := []struct { + name string + file string + source string + want int + }{ + {"raw URL", "cmd/x.go", `package p; func f() { _ = "https://github.com/acme/project" }`, 1}, + {"wrapped URL", "cmd/x.go", `package p; import rewrite "github.com/larksuite/cli/internal/urlrewrite"; func f() { _ = rewrite.Rewrite("https://github.com/acme/project") }`, 0}, + {"package initialization is too early", "cmd/x.go", `package p; import rewrite "github.com/larksuite/cli/internal/urlrewrite"; var u = rewrite.Rewrite("https://github.com/acme/project")`, 1}, + {"static concatenation", "shortcuts/x/x.go", `package p; func f() { _ = "https://" + "github.com/acme/project" }`, 1}, + {"documented exemption", "cmd/x.go", "package p\nfunc f() {\n//nolint:urlrewrite protocol namespace\n_ = \"https://www.larkoffice.com/sml/2.0\"\n}\n", 0}, + {"test fixture", "cmd/x_test.go", `package p; var u = "https://github.com/acme/project"`, 0}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, tc.file, tc.source, parser.ParseComments) + if err != nil { + t.Fatal(err) + } + scan := newFileDomainScan(typedGoFile{File: file, Fset: fset}) + scan.collectAbsoluteURLEvidence() + got := 0 + for _, evidence := range scan.Evidence { + if _, ok := scan.unrewrittenURLViolation(tc.file, evidence, []addedLineRange{{Start: 1, End: 100}}); ok { + got++ + } + } + if got != tc.want { + t.Fatalf("violations = %d, want %d", got, tc.want) + } + }) + } +} From f2ab837a5940377cc300db46fd920e662cf17e55 Mon Sep 17 00:00:00 2001 From: "guokexin.02" <264159873+Tantanz20020918@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:57:33 +0800 Subject: [PATCH 7/8] refactor(lint): simplify URL rewrite guard - Merge the Rewrite-call and resolver-body checks into a single ancestor walk (urlRewriteExempt), with import alias resolution factored out. - Drop the package-scope Rewrite rule: it only caught direct package-level calls while indirect init-time rewriting (the real risk shape, e.g. rootUsageTemplate) passes through anyway, and it forced root help into a function indirection. The build-time re-render is the actual fix for stale help URLs. - Inline the skills setup URL at the Rewrite call site in root help. --- cmd/root_help.go | 6 +- lint/domaincontract/urlrewrite.go | 79 ++++++++++++-------------- lint/domaincontract/urlrewrite_test.go | 1 - 3 files changed, 37 insertions(+), 49 deletions(-) diff --git a/cmd/root_help.go b/cmd/root_help.go index e2309f1edd..94d62e480b 100644 --- a/cmd/root_help.go +++ b/cmd/root_help.go @@ -145,17 +145,13 @@ Skills setup (one-time, humans): npx skills add larksuite/cli -g -y — %s{{end} var rootUsageTemplate = renderRootUsageTemplate(nil) -func skillsSetupURL() string { - return urlrewrite.Rewrite("https://github.com/larksuite/cli#agent-skills") -} - func renderRootUsageTemplate(plan *surface.Plan) string { var b strings.Builder b.WriteString(rootUsageTemplatePrefix) b.WriteString(renderRootHelpFragments(rootUsageSynopsis, plan)) b.WriteString(rootUsageTemplateSuffix) if plan.CanReference(surface.CommandSkillsRead) { - fmt.Fprintf(&b, skillsSetupFooter, skillsSetupURL()) + fmt.Fprintf(&b, skillsSetupFooter, urlrewrite.Rewrite("https://github.com/larksuite/cli#agent-skills")) } b.WriteByte('\n') return b.String() diff --git a/lint/domaincontract/urlrewrite.go b/lint/domaincontract/urlrewrite.go index 5d555b6242..ac7e4b7651 100644 --- a/lint/domaincontract/urlrewrite.go +++ b/lint/domaincontract/urlrewrite.go @@ -14,9 +14,10 @@ import ( const urlRewriteImport = "github.com/larksuite/cli/internal/urlrewrite" +// unrewrittenURLViolation rejects an added static URL in CLI runtime code that +// is not wrapped in urlrewrite.Rewrite. func (s *fileDomainScan) unrewrittenURLViolation(rel string, evidence domainEvidence, added []addedLineRange) (lintapi.Violation, bool) { - if evidence.Kind != "absolute URL" || !urlRewriteRuntimeFile(rel) || - (rel == resolverPath && s.inEndpointResolver(evidence.Expr)) || s.inURLRewriteCall(evidence.Expr) || s.hasURLRewriteExemption(evidence.Expr) { + if evidence.Kind != "absolute URL" || !urlRewriteRuntimeFile(rel) || s.urlRewriteExempt(rel, evidence.Expr) { return lintapi.Violation{}, false } start := s.Fset.Position(evidence.Expr.Pos()).Line @@ -35,20 +36,34 @@ func (s *fileDomainScan) unrewrittenURLViolation(rel string, evidence domainEvid }, true } -func urlRewriteRuntimeFile(rel string) bool { - rel = filepath.ToSlash(rel) - if strings.HasSuffix(rel, "_test.go") || strings.Contains(rel, "/testdata/") || - strings.HasPrefix(rel, "internal/qualitygate/") || strings.HasPrefix(rel, "internal/testutil/") || - strings.HasPrefix(rel, "internal/urlrewrite/") { - return false +// urlRewriteExempt reports whether a static URL needs no Rewrite wrapper: it +// sits inside the endpoint resolver body (platform URLs are rewritten by the +// transport layer), inside a urlrewrite.Rewrite call, or next to a documented +// //nolint:urlrewrite reason. +func (s *fileDomainScan) urlRewriteExempt(rel string, expr ast.Expr) bool { + aliases := urlRewriteAliases(s.File) + for node := ast.Node(expr); node != nil; node = s.parents[node] { + switch n := node.(type) { + case *ast.CallExpr: + sel, ok := n.Fun.(*ast.SelectorExpr) + pkg, pkgOK := sel.X.(*ast.Ident) + if ok && pkgOK && aliases[pkg.Name] && sel.Sel.Name == "Rewrite" { + return true + } + case *ast.FuncDecl: + if rel == resolverPath && n.Recv == nil && n.Name.Name == "ResolveEndpoints" { + return true + } + } } - return rel == "main.go" || strings.HasPrefix(rel, "cmd/") || - strings.HasPrefix(rel, "internal/") || strings.HasPrefix(rel, "shortcuts/") + return s.hasURLRewriteExemption(expr) } -func (s *fileDomainScan) inURLRewriteCall(expr ast.Expr) bool { +// urlRewriteAliases returns the import names under which this file imports the +// urlrewrite package (usually just "urlrewrite"). +func urlRewriteAliases(file *ast.File) map[string]bool { aliases := map[string]bool{} - for _, imp := range s.File.Imports { + for _, imp := range file.Imports { path, err := strconv.Unquote(imp.Path.Value) if err != nil || path != urlRewriteImport { continue @@ -59,40 +74,18 @@ func (s *fileDomainScan) inURLRewriteCall(expr ast.Expr) bool { } aliases[name] = true } - for node := ast.Node(expr); node != nil; node = s.parents[node] { - call, ok := node.(*ast.CallExpr) - if !ok { - continue - } - sel, ok := call.Fun.(*ast.SelectorExpr) - pkg, pkgOK := sel.X.(*ast.Ident) - if ok && pkgOK && aliases[pkg.Name] && sel.Sel.Name == "Rewrite" && !s.atPackageScope(call) { - return true - } - } - return false + return aliases } -func (s *fileDomainScan) atPackageScope(node ast.Node) bool { - for parent := s.parents[node]; parent != nil; parent = s.parents[parent] { - switch parent.(type) { - case *ast.FuncDecl, *ast.FuncLit: - return false - } - if _, ok := parent.(*ast.File); ok { - return true - } - } - return false -} - -func (s *fileDomainScan) inEndpointResolver(expr ast.Expr) bool { - for node := ast.Node(expr); node != nil; node = s.parents[node] { - if fn, ok := node.(*ast.FuncDecl); ok { - return fn.Recv == nil && fn.Name.Name == "ResolveEndpoints" - } +func urlRewriteRuntimeFile(rel string) bool { + rel = filepath.ToSlash(rel) + if strings.HasSuffix(rel, "_test.go") || strings.Contains(rel, "/testdata/") || + strings.HasPrefix(rel, "internal/qualitygate/") || strings.HasPrefix(rel, "internal/testutil/") || + strings.HasPrefix(rel, "internal/urlrewrite/") { + return false } - return false + return rel == "main.go" || strings.HasPrefix(rel, "cmd/") || + strings.HasPrefix(rel, "internal/") || strings.HasPrefix(rel, "shortcuts/") } func (s *fileDomainScan) hasURLRewriteExemption(expr ast.Expr) bool { diff --git a/lint/domaincontract/urlrewrite_test.go b/lint/domaincontract/urlrewrite_test.go index 72216a5c06..c13893e016 100644 --- a/lint/domaincontract/urlrewrite_test.go +++ b/lint/domaincontract/urlrewrite_test.go @@ -18,7 +18,6 @@ func TestStaticURLRewriteGuard(t *testing.T) { }{ {"raw URL", "cmd/x.go", `package p; func f() { _ = "https://github.com/acme/project" }`, 1}, {"wrapped URL", "cmd/x.go", `package p; import rewrite "github.com/larksuite/cli/internal/urlrewrite"; func f() { _ = rewrite.Rewrite("https://github.com/acme/project") }`, 0}, - {"package initialization is too early", "cmd/x.go", `package p; import rewrite "github.com/larksuite/cli/internal/urlrewrite"; var u = rewrite.Rewrite("https://github.com/acme/project")`, 1}, {"static concatenation", "shortcuts/x/x.go", `package p; func f() { _ = "https://" + "github.com/acme/project" }`, 1}, {"documented exemption", "cmd/x.go", "package p\nfunc f() {\n//nolint:urlrewrite protocol namespace\n_ = \"https://www.larkoffice.com/sml/2.0\"\n}\n", 0}, {"test fixture", "cmd/x_test.go", `package p; var u = "https://github.com/acme/project"`, 0}, From ae2813b58424be385b444407a5f9ec6ca3fbe970 Mon Sep 17 00:00:00 2001 From: "guokexin.02" <264159873+Tantanz20020918@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:57:34 +0800 Subject: [PATCH 8/8] fix(auth): rewrite app registration confirmation URL The interactive config init confirmation page (open./page/cli) is CLI-owned presentation shown as a link/QR code, never fetched by the CLI. Pass it through the URL rewrite extension so mirrored deployments show the reachable host, preserving user_code and tracking params. --- internal/auth/app_registration.go | 5 ++++- internal/auth/app_registration_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/internal/auth/app_registration.go b/internal/auth/app_registration.go index 44d5c3af95..495bc60847 100644 --- a/internal/auth/app_registration.go +++ b/internal/auth/app_registration.go @@ -15,6 +15,7 @@ import ( "time" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/urlrewrite" ) // Terminal registration outcomes, exposed for typed classification by callers. @@ -156,7 +157,9 @@ func RequestAppRegistration(ctx context.Context, httpClient *http.Client, brand userCode := getStr(data, "user_code") verificationUri := getStr(data, "verification_uri") - verificationUriComplete := fmt.Sprintf("%s/page/cli?user_code=%s", ep.Open, userCode) + // The confirmation page is CLI-owned presentation (shown as a link/QR code, + // never fetched), so it passes through the URL rewrite extension. + verificationUriComplete := urlrewrite.Rewrite(fmt.Sprintf("%s/page/cli?user_code=%s", ep.Open, userCode)) return &AppRegistrationResponse{ DeviceCode: deviceCode, diff --git a/internal/auth/app_registration_test.go b/internal/auth/app_registration_test.go index 64993e9ccb..be4b80ff97 100644 --- a/internal/auth/app_registration_test.go +++ b/internal/auth/app_registration_test.go @@ -13,6 +13,7 @@ import ( "time" "github.com/larksuite/cli/internal/core" + testurlrewrite "github.com/larksuite/cli/internal/testutil/urlrewrite" "github.com/smartystreets/goconvey/convey" ) @@ -91,6 +92,31 @@ func TestRequestAppRegistration_UsesFeishuBootstrapAndConfiguredVerificationBran } } +// TestRequestAppRegistration_RewritesVerificationURL pins the presentation +// boundary: the final CLI-built confirmation page URL passes through the URL +// rewrite extension for both brands without losing its query parameters. +func TestRequestAppRegistration_RewritesVerificationURL(t *testing.T) { + testurlrewrite.Register(t, func(rawURL string) string { + rawURL = strings.Replace(rawURL, "open.feishu.cn", "open.mirror.test", 1) + return strings.Replace(rawURL, "open.larksuite.com", "open.mirror.test", 1) + }) + client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + return jsonResponse(`{"device_code":"d","user_code":"TEST-CODE","expire_in":60,"interval":5}`), nil + })} + + for _, brand := range []core.LarkBrand{core.BrandFeishu, core.BrandLark} { + resp, err := RequestAppRegistration(context.Background(), client, brand, io.Discard) + if err != nil { + t.Fatalf("RequestAppRegistration(%q) error = %v", brand, err) + } + got := BuildVerificationURL(resp.VerificationUriComplete, "1.2.3") + want := "https://open.mirror.test/page/cli?user_code=TEST-CODE&lpv=1.2.3&ocv=1.2.3&from=cli" + if got != want { + t.Errorf("brand %q: verification URL = %q, want %q", brand, got, want) + } + } +} + // Full Lark routing contract: Lark selects the Lark verification page, while // registration bootstraps on Feishu and switches only after the tenant signal. // The Lark credential response omits user_info, so the effective domain must