diff --git a/cmd/build.go b/cmd/build.go index 9b30053f05..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 @@ -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(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/command_sets_test.go b/cmd/command_sets_test.go index 29f9df7176..7aac595556 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 == "https://github.com/larksuite/cli#agent-skills" { + 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/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..94d62e480b 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 @@ -134,10 +136,12 @@ 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}}` var rootUsageTemplate = renderRootUsageTemplate(nil) @@ -147,7 +151,7 @@ func renderRootUsageTemplate(plan *surface.Plan) string { b.WriteString(renderRootHelpFragments(rootUsageSynopsis, plan)) b.WriteString(rootUsageTemplateSuffix) if plan.CanReference(surface.CommandSkillsRead) { - b.WriteString(skillsSetupFooter) + fmt.Fprintf(&b, skillsSetupFooter, urlrewrite.Rewrite("https://github.com/larksuite/cli#agent-skills")) } b.WriteByte('\n') return b.String() 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..3dff4f260a 100644 --- a/cmd/update/update.go +++ b/cmd/update/update.go @@ -19,6 +19,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 ( @@ -189,6 +190,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 +243,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 +268,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 +276,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 +287,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 +302,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 +324,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 +342,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 +372,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 +387,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 +395,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 +412,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..0fab1c2145 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 }() 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..4969205c9e 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 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 +} + +// 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 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/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 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"}) diff --git a/internal/errclass/classify.go b/internal/errclass/classify.go index 4418d756e8..0110d264a9 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 @@ -568,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 base + if len(scopes) > 0 { + base += "&scopes=" + url.QueryEscape(strings.Join(scopes, ",")) } - return base + "&scopes=" + url.QueryEscape(strings.Join(scopes, ",")) + return urlrewrite.Rewrite(base) } 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..ca1ab8640f 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" ) @@ -343,7 +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" - 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 { @@ -358,7 +359,7 @@ func (u *Updater) RemoveGlobalSkills(names []string) *NpmResult { } func (u *Updater) runSkillsAdd(source string) *NpmResult { - 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 { @@ -366,7 +367,7 @@ func (u *Updater) runSkillsListGlobal() *NpmResult { } func (u *Updater) runSkillsInstall(source string, nameList []string) *NpmResult { - 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...) 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/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.go b/internal/transport/extension.go index 0243e6ea0c..50508a68d2 100644 --- a/internal/transport/extension.go +++ b/internal/transport/extension.go @@ -5,9 +5,12 @@ package transport import ( "context" + "fmt" "net/http" + "net/url" exttransport "github.com/larksuite/cli/extension/transport" + "github.com/larksuite/cli/internal/urlrewrite" ) var _ RoundTripperDecorator = (*ExtensionMiddleware)(nil) @@ -15,6 +18,7 @@ var _ RoundTripperDecorator = (*ExtensionMiddleware)(nil) type resolvedExtension struct { provider exttransport.Provider interceptor exttransport.Interceptor + rewriter exttransport.URLRewriter } func resolveExtension() *resolvedExtension { @@ -22,11 +26,17 @@ func resolveExtension() *resolvedExtension { if p == nil { return nil } - interceptor := p.ResolveInterceptor(context.Background()) - if interceptor == nil { + + ctx := context.Background() + extension := &resolvedExtension{ + provider: p, + interceptor: p.ResolveInterceptor(ctx), + rewriter: urlrewrite.ResolveProvider(ctx, 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,32 @@ func (e *resolvedExtension) wrap(base http.RoundTripper, class exttransport.Requ if e == nil { return base } - if enforceScope { + interceptor := e.interceptor + rewriter := e.rewriter + 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()} + // 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: 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 +80,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 exttransport.URLRewriter } // BaseRoundTripper returns the wrapped built-in transport chain. @@ -80,15 +107,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.RewriteURL(req.URL.String()) + if rewritten != req.URL.String() { + rewrittenURL, err := url.Parse(rewritten) + if err != nil { + return nil, fmt.Errorf("extension %q rewrote request URL to an invalid value: %w", m.ExtName, 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 { @@ -105,16 +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 or no resolved interceptor, 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 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 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 5b83f7f69a..abc9df8d3c 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, WrapWithExtensionForClass(base, exttransport.RequestClassPlatform), req) + + if interceptor.url != "https://mirror.example.test/path" { + t.Fatalf("interceptor URL = %q, want rewritten URL", interceptor.url) + } +} + +func TestHTTPPolicyRouterRewritesPlatformButPreservesExternalURL(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.example.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://open.feishu.cn/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 @@ -414,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") 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) { diff --git a/internal/urlrewrite/rewrite.go b/internal/urlrewrite/rewrite.go new file mode 100644 index 0000000000..30a3315ff5 --- /dev/null +++ b/internal/urlrewrite/rewrite.go @@ -0,0 +1,36 @@ +// 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" +) + +// 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 nil + } + return p.ResolveURLRewriter(ctx) +} + +// 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 { + rewriter := ResolveProvider(context.Background(), exttransport.GetProvider()) + if rewriter == nil { + return rawURL + } + return rewriter.RewriteURL(rawURL) +} diff --git a/internal/urlrewrite/rewrite_test.go b/internal/urlrewrite/rewrite_test.go new file mode 100644 index 0000000000..b1820ac60b --- /dev/null +++ b/internal/urlrewrite/rewrite_test.go @@ -0,0 +1,53 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package urlrewrite + +import ( + "context" + "testing" + + exttransport "github.com/larksuite/cli/extension/transport" + testurlrewrite "github.com/larksuite/cli/internal/testutil/urlrewrite" +) + +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) { + for _, provider := range []exttransport.Provider{nil, testProvider{}} { + if got := ResolveProvider(context.Background(), provider); got != nil { + t.Fatalf("ResolveProvider(%T) = %v, want nil", provider, 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" + 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/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..ac7e4b7651 --- /dev/null +++ b/lint/domaincontract/urlrewrite.go @@ -0,0 +1,107 @@ +// 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" + +// 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) || s.urlRewriteExempt(rel, 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 +} + +// 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 s.hasURLRewriteExemption(expr) +} + +// 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 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 + } + return aliases +} + +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) 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..c13893e016 --- /dev/null +++ b/lint/domaincontract/urlrewrite_test.go @@ -0,0 +1,45 @@ +// 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}, + {"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) + } + }) + } +} diff --git a/shortcuts/apps/apps_init.go b/shortcuts/apps/apps_init.go index 4fd250295f..c990a95c1c 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" ) @@ -419,9 +420,12 @@ func runScaffold(ctx context.Context, dir, appID, appType, sourcePath string) (s } 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", 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 +433,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)) } } @@ -444,9 +448,14 @@ 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 { - base := []string{"-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, "app", "init"} + return scaffoldInitArgsWithRegistry(urlrewrite.Rewrite(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..0c68b775e2 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 path string switch strings.ToLower(strings.TrimSpace(kind)) { case "docx": - return host + "/docx/" + token + path = "/docx/" case "doc": - return host + "/doc/" + token + path = "/doc/" case "sheet": - return host + "/sheets/" + token + path = "/sheets/" case "bitable": - return host + "/base/" + token + path = "/base/" case "wiki": - return host + "/wiki/" + token + path = "/wiki/" case "file": - return host + "/file/" + token + path = "/file/" case "folder": - return host + "/drive/folder/" + token + path = "/drive/folder/" case "mindnote": - return host + "/mindnote/" + token + path = "/mindnote/" case "slides": - return host + "/slides/" + token + path = "/slides/" default: return "" } + 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 7c94127027..a4e87d3c3a 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 { @@ -117,7 +119,10 @@ 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) } 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..77f401166c 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.test/base" + } + return raw + }) + + if got := newIMMarkdownContext("doc_token").baseURL; got != "https://tenant.example.test/base" { + t.Fatalf("baseURL = %q", got) + } +} + func TestConvertToIMMarkdownTitle(t *testing.T) { t.Parallel() 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/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/convert_lib/content_convert.go b/shortcuts/im/convert_lib/content_convert.go index 1292b7d33c..992397bebf 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" ) @@ -274,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) @@ -283,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 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 u.String() } - return "" + if u == nil { + return "" + } + return urlrewrite.Rewrite(u.String()) } func normalizeMessagePosition(v interface{}) (string, bool) { diff --git a/shortcuts/mail/large_attachment.go b/shortcuts/mail/large_attachment.go index 97536fa6a2..0388e33af4 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" @@ -196,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, @@ -271,7 +280,7 @@ 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(largeAttachmentIconURL(iconCDN, att.FileName)), htmlEscape(att.FileName), htmlEscape(common.FormatSize(att.FileSize)), htmlEscape(buildLargeAttachmentPreviewURL(brand, att.FileToken)), 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