diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index 76da61ed..c28fef26 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -1,7 +1,9 @@ package setup import ( + "context" "fmt" + "time" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -51,7 +53,7 @@ project and environment screens, pass --project and --environment.`, viper.GetBool(cliflags.AnalyticsOptOut), ).SendCommandRunEvent(cmdAnalytics.CmdRunEventProperties(cmd, "setup", nil)) }, - RunE: runSetupWizard(analyticsTrackerFn, svc, runner), + RunE: withConfigSource(runner, runSetupWizard(analyticsTrackerFn, svc, runner)), } cmd.Flags().String(cliflags.ProjectFlag, "", "The project key. Setup skips the project screen.") cmd.Flags().String(cliflags.EnvironmentFlag, "", "The environment key. Setup skips the environment screen.") @@ -59,7 +61,32 @@ project and environment screens, pass --project and --environment.`, cmd.AddCommand(newDetectCmd(svc)) cmd.AddCommand(newInstallCmd(svc)) cmd.AddCommand(newInitCmd(svc)) - cmd.AddCommand(newAgentCmd(svc, runner)) + agentCmd := newAgentCmd(svc, runner) + agentCmd.RunE = withConfigSource(runner, agentCmd.RunE) + cmd.AddCommand(agentCmd) return cmd } + +// withConfigSource gives the agent the setup-agent AI Config for the +// logged-in user, and closes it after the command runs, so the AI SDK sends +// its events before ldcli exits. The source is built when the command runs, +// because the access token and the base URI come from flags and the +// configuration file. +func withConfigSource(runner *agent.OpenCode, run func(*cobra.Command, []string) error) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + source := agent.NewAIConfigSource( + viper.GetString(cliflags.BaseURIFlag), + viper.GetString(cliflags.AccessTokenFlag), + ) + runner.Config = source + if closer, ok := source.(interface{ Close(context.Context) error }); ok { + defer func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = closer.Close(ctx) + }() + } + return run(cmd, args) + } +} diff --git a/go.mod b/go.mod index 5b4c3ef8..2cb5b664 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/launchdarkly/ldcli -go 1.26 +go 1.26.0 toolchain go1.26.10 @@ -9,7 +9,8 @@ require ( github.com/adrg/xdg v0.5.3 github.com/atotto/clipboard v0.1.4 github.com/aws/aws-sdk-go-v2 v1.47.3 - github.com/aws/aws-sdk-go-v2/config v1.33.5 + github.com/aws/aws-sdk-go-v2/config v1.33.9 + github.com/aws/aws-sdk-go-v2/credentials v1.20.9 github.com/aws/aws-sdk-go-v2/service/devopsagent v1.17.0 github.com/aws/aws-sdk-go-v2/service/iam v1.64.0 github.com/aws/aws-sdk-go-v2/service/sts v1.51.4 @@ -29,6 +30,7 @@ require ( github.com/iancoleman/strcase v0.3.0 github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f github.com/launchdarkly/api-client-go/v24 v24.0.0 + github.com/launchdarkly/go-ai-sdk v0.0.0-20261016194641-6152dc96d702 github.com/launchdarkly/go-sdk-common/v3 v3.6.0 github.com/launchdarkly/go-server-sdk/v7 v7.19.1 github.com/launchdarkly/sdk-meta/api v0.7.5 @@ -45,10 +47,10 @@ require ( github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 - github.com/stretchr/testify v1.11.1 + github.com/stretchr/testify v1.12.1 go.uber.org/mock v0.6.0 golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa - golang.org/x/term v0.40.0 + golang.org/x/term v0.46.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -56,37 +58,39 @@ require ( github.com/BurntSushi/toml v1.3.2 // indirect github.com/alecthomas/chroma/v2 v2.14.0 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.20.5 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.0 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.22 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.3 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.6 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.6 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.6 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.21 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.6 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.10.0 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.38.0 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.0 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.10.4 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.38.4 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.4 // indirect github.com/aws/smithy-go v1.28.5 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/catppuccin/go v0.3.0 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dlclark/regexp2 v1.11.0 // indirect github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/go-openapi/jsonpointer v0.22.5 // indirect - github.com/go-openapi/swag/jsonname v0.25.5 // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect + github.com/go-logr/logr v1.4.4 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/jsonpointer v1.0.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/gregjones/httpcache v0.0.0-20171119193500-2bcd89a1743f // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/launchdarkly/ccache v1.1.0 // indirect @@ -118,19 +122,32 @@ require ( github.com/speakeasy-api/openapi v1.19.2 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect - github.com/stretchr/objx v0.5.2 // indirect + github.com/stretchr/objx v0.5.3 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark v1.7.8 // indirect github.com/yuin/goldmark-emoji v1.0.5 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/mod v0.33.0 // indirect - golang.org/x/net v0.50.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.34.0 // indirect - golang.org/x/tools v0.42.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.47.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.47.0 // indirect + go.opentelemetry.io/otel/log v1.47.0 // indirect + go.opentelemetry.io/otel/metric v1.47.0 // indirect + go.opentelemetry.io/otel/sdk v1.47.0 // indirect + go.opentelemetry.io/otel/trace v1.47.0 // indirect + go.opentelemetry.io/proto/otlp v1.11.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/mod v0.41.0 // indirect + golang.org/x/net v0.60.0 // indirect + golang.org/x/sync v0.23.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect + golang.org/x/tools v0.49.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc // indirect + google.golang.org/grpc v1.83.2 // indirect + google.golang.org/protobuf v1.36.12 // indirect gopkg.in/validator.v2 v2.0.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index cd8637fa..12feb472 100644 --- a/go.sum +++ b/go.sum @@ -20,14 +20,14 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go-v2 v1.47.3 h1:HRoT/MkNi3bjO5xF4dCzLzvVmkvRGHj3/cLINDnJejk= github.com/aws/aws-sdk-go-v2 v1.47.3/go.mod h1:xSl+OSI5ZUw6bSMupWc218zE5Pl+65MCCu3ySWFY99A= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 h1:GPRlPwz40I2B2VrBEASOA3Bi77NyeqejNLkifosX0rs= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20/go.mod h1:g7PNzKcsOKWb4fkSRBA7BZVAS6Y8IcxzN+nRohhQ1Q8= -github.com/aws/aws-sdk-go-v2/config v1.33.5 h1:UA1dmokBFOLFoOyVBhO6HjM6edy0MIk5AZSkJVcksQw= -github.com/aws/aws-sdk-go-v2/config v1.33.5/go.mod h1:Dop8axzz0xx38GExIYWXdeyc8QQ7Cr+nPsxpD/LYy4U= -github.com/aws/aws-sdk-go-v2/credentials v1.20.5 h1:wklUVvHMc9xTQ3rcp49/ISpiMnhbCicJcA6n6S8m7J8= -github.com/aws/aws-sdk-go-v2/credentials v1.20.5/go.mod h1:fyEdrn6ccLFOkoK84j5bQyGTxp9zPt5l2XMhxf4DVZs= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.0 h1:AM4hHjww+PSFtt6E+UrBrPlZkWsePCLEt9AjkfQX+yM= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.0/go.mod h1:3x/yXezeQjpOvBb4jEMxrS8SXvpdvJ5abv6l5c1gWM8= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.22 h1:oiEmPz/YMMeh/JvAE8xSxNCIsvWE6lpFe4ZPlyFykQI= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.22/go.mod h1:QWUDuRi9PFjBHIU2iH1Pkldbe7zqLfXJC4wWLbF8nuQ= +github.com/aws/aws-sdk-go-v2/config v1.33.9 h1:Hv6bcQUJVnmuWaKjqD6Ot8sE75NrWsWV20oGT5aWle0= +github.com/aws/aws-sdk-go-v2/config v1.33.9/go.mod h1:ivowmrZNfBQLcg4QkaItgi5wae4Bzot9RzIdbY1hfTg= +github.com/aws/aws-sdk-go-v2/credentials v1.20.9 h1:SNO55PnWhaDafsOcv16lCA0kdoFMl/YlegWoVsU02s0= +github.com/aws/aws-sdk-go-v2/credentials v1.20.9/go.mod h1:+2reOTRtdD3VSBqpRDNwrZIYy8yMCYa7oK3WKTJ/I1I= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.3 h1:gr3b87OLB29jymWPzp/CAyXwQRJVE+sC4Wm0qBIKHsk= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.3/go.mod h1:BRjf4L/Yx2hY54o+ZibPbLJyMMeSwqYQXwcJfqW66a8= github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.6 h1:CqL8NN3p4I0JTwwV5MEoatIl4Y2amZ2X9dRD+0hbRL0= github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.6/go.mod h1:exdJ32hMqXl0Wyo6NwqEC5yIhtua3+PDaKE8P29m4qE= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.6 h1:FpBq24TG6WVSObTNhQOv7Q0088rounUUlwn3zlNItkM= @@ -42,12 +42,12 @@ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.21 h1:e6YW5u github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.21/go.mod h1:obpHAMP693+f3/A8PkLhIqfK1C+NWWn8zXzqD3OH520= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.6 h1:LHHdobjo5le1sjJdolitV7uVvYg3wkaeM1Ju2fDgeZQ= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.6/go.mod h1:CBGB0evAiaMD3Lk4wPO7LQCcCZLuhzL5Qf/wfEUHQ5w= -github.com/aws/aws-sdk-go-v2/service/signin v1.10.0 h1:ZD5qFpWcaOKdTuhBi431pIDkCgrMkMlMT6jlpSPoIRI= -github.com/aws/aws-sdk-go-v2/service/signin v1.10.0/go.mod h1:8Nuuf+tR346PjJ3MvZPh9pekbLiLQFWJhzMXfwy7alA= -github.com/aws/aws-sdk-go-v2/service/sso v1.38.0 h1:JGeeBcMlhg1xtOXYpeCaTQBZObtXMPQCUqBcmr65NRA= -github.com/aws/aws-sdk-go-v2/service/sso v1.38.0/go.mod h1:XwteswG9EOMRFm73UT0t+MbTwyLxMrEXkU6e+v92Lzo= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.0 h1:obhahQXDEdVEv8y5bTKXR30LVaxYe1kyYM0L7l2Iq+k= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.0/go.mod h1:6twZZ/aXHNy1vXUO8koUbp++MYzMASkOgEBdkbJYmO0= +github.com/aws/aws-sdk-go-v2/service/signin v1.10.4 h1:tvpDjuUvgKURf3Yk4hOaGbZ4QdY+070OWcA5d6LUv2A= +github.com/aws/aws-sdk-go-v2/service/signin v1.10.4/go.mod h1:3ibVKg5Ng82Zby8++jELTD2nHxiPk2DJRLEeW/LKp8M= +github.com/aws/aws-sdk-go-v2/service/sso v1.38.4 h1:ChCtagixSFOxltv0o6E3NT8F7ksvZtZGPwUBynO5Nmk= +github.com/aws/aws-sdk-go-v2/service/sso v1.38.4/go.mod h1:+FEO9jrVMpc9oN3CdAxJbtlQEu3z8UddDPSedpeM3PY= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.4 h1:j6c+HA4AoS0EBEjArPjavFEBrucJEryfrQU0TffmACk= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.4/go.mod h1:RhghaXUk9q66VtOQUX1FdPddQuHnqQZzbB0fEYXmPRA= github.com/aws/aws-sdk-go-v2/service/sts v1.51.4 h1:OKEfbf2tcfsF2pBk/S9QqNpo6alaAtiBs4hysCsBjPw= github.com/aws/aws-sdk-go-v2/service/sts v1.51.4/go.mod h1:mCd6qGo9b8hTtfjgsmNkFlzwjyce4owd7HDVFnCLXhM= github.com/aws/smithy-go v1.28.5 h1:OqbqKtzY9NUnr2yAe9+hiTdFb20tNaK/lI0PMOGXX9k= @@ -65,6 +65,10 @@ github.com/blacktop/go-macho v1.1.282/go.mod h1:Hc5E2Lvt/U1VT+jOxr1O5l/LNFJeMYK4 github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= @@ -105,8 +109,6 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58= @@ -116,8 +118,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= -github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= -github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= @@ -126,15 +128,18 @@ github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx5 github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/getkin/kin-openapi v0.144.0 h1:hIRcTH+KjLfkLpYU6bSSfdFpi0fZi1fp+hSPi4aQu9Y= github.com/getkin/kin-openapi v0.144.0/go.mod h1:3BH9M9XDe/y9M5DSvEocVYAYq1w0qrhJHjC/vZi0AaY= -github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= -github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= -github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= -github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU= -github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM= -github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s= +github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y= +github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug= +github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= @@ -144,12 +149,14 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -161,6 +168,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gregjones/httpcache v0.0.0-20171119193500-2bcd89a1743f h1:kOkUP6rcVVqC+KlKKENKtgfFfJyDySYhqL9srXooghY= github.com/gregjones/httpcache v0.0.0-20171119193500-2bcd89a1743f/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= @@ -191,6 +200,8 @@ github.com/launchdarkly/ccache v1.1.0 h1:voD1M+ZJXR3MREOKtBwgTF9hYHl1jg+vFKS/+VA github.com/launchdarkly/ccache v1.1.0/go.mod h1:TlxzrlnzvYeXiLHmesMuvoZetu4Z97cV1SsdqqBJi1Q= github.com/launchdarkly/eventsource v1.14.0 h1:6lxVkUwxCAEbBrx8J/N9i25yOajC6NNwq0Voo+fT0Kc= github.com/launchdarkly/eventsource v1.14.0/go.mod h1:dU+rZxkPOlGPsyJPpiDqiepAcFwIITDUClY9+A6RrMw= +github.com/launchdarkly/go-ai-sdk v0.0.0-20261016194641-6152dc96d702 h1:HhCHqLB8Qvyd2YnATyaCwOsViAZwYTwSQhWHzEV2Dfg= +github.com/launchdarkly/go-ai-sdk v0.0.0-20261016194641-6152dc96d702/go.mod h1:l4YMlzJALpe2Y5Xjy/4r55r+IRw2uIvxckTWlGKqh6Y= github.com/launchdarkly/go-jsonstream/v3 v3.1.2 h1:Od0QEKHesbKnD3GgAt+LkZzhcUiSP+yhrclrhsoox6w= github.com/launchdarkly/go-jsonstream/v3 v3.1.2/go.mod h1:ZBjhKq8mhArCtqotGRGnteY6eXpNm1GaOdUSZHh+ZjM= github.com/launchdarkly/go-sdk-common/v3 v3.6.0 h1:DxDfcNcRMxN2E8X7aWfOLcVvHgD2iUgFRYCcZtVcK3w= @@ -275,8 +286,8 @@ github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= @@ -307,14 +318,14 @@ github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk= @@ -329,18 +340,41 @@ github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.47.0 h1:j7ALJ/zgkS7Z6aeJW09p8VC9804bC+PpeTfCD4XPnOM= +go.opentelemetry.io/otel v1.47.0/go.mod h1:8wS9O2qfXrYrzp6hIF/HOYJJf/wIhFPhR2xLuP+iXQU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0 h1:julhjPeUH/q/7hinbSdDdqt5h7Zw9YWmRlWRhI0jd54= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0/go.mod h1:Ao2mz688LH/tFf0yMAenidq6k2YNSx6SIY2q6jDACck= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.47.0 h1:aXZXsZ012wOgVkqaiQv+Z/d8V0xKjmg70F6m4CE94lc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.47.0/go.mod h1:ziu1gUIJhAaxM3EV1e3Ralk5AAyL1UOYz3lJJ4VSI38= +go.opentelemetry.io/otel/log v1.47.0 h1:cOTS1CcLbSQeZKanGJ+0JpF/+t4PELi3O3bbl2lqCcI= +go.opentelemetry.io/otel/log v1.47.0/go.mod h1:9byitSQ5pLC6PpqwGXjqdMKya6ZTswHRZh2vvXT33nw= +go.opentelemetry.io/otel/metric v1.47.0 h1:4PptaldXx3Eat1XjMZ68pPJEs5wrhlemctZE9a3UdWY= +go.opentelemetry.io/otel/metric v1.47.0/go.mod h1:ADGSXxRrXM6bjbvLo535EstVFlPpPYZm4LBKixjDHwU= +go.opentelemetry.io/otel/sdk v1.47.0 h1:zWXEr4j2lFefG87TU6Yg8a7ngfohIKFZHKp0Hf5hC6I= +go.opentelemetry.io/otel/sdk v1.47.0/go.mod h1:VUc24kiOeoGsxG8G9ULx3fWKvB7jMhnGE8Oi607lgR0= +go.opentelemetry.io/otel/sdk/metric v1.47.0 h1:lfISg2j93VT6yqdk9OfUaZmw/GfcZqCCV3jdXtsPnKw= +go.opentelemetry.io/otel/sdk/metric v1.47.0/go.mod h1:ypLp+mW1Nt2x+Szt3b5/i1syodyts49lMOwxpDI3VGw= +go.opentelemetry.io/otel/trace v1.47.0 h1:JOjX/Oci8K94QHddo+bbfya/Ai/nf6/dt9ZfrFNWSrM= +go.opentelemetry.io/otel/trace v1.47.0/go.mod h1:jNaSLa2PZEYFG6fRjJABAu+bw4FS08uDmPg28lTghu0= +go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= +go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa h1:ELnwvuAXPNtPk1TJRuGkI9fDTwym6AYBu0qzT8AcHdI= golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -348,13 +382,13 @@ golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= +golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -372,27 +406,35 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= -golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= -golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc h1:rZHRz0ogq4Pid1IKtA/ivHi8XONVF5tqUQXA2TtgpkE= +google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:K/n0XLazCJHjD73YuMyNJsN38BPPy1kfFnKxUXaThFU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc h1:4bNTbnb44EqGVy9HaQxSY2jnafSUdgV3qHtedvNpDKg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -401,6 +443,8 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/internal/setup/agent/agent.go b/internal/setup/agent/agent.go index 7116c058..6fcaf103 100644 --- a/internal/setup/agent/agent.go +++ b/internal/setup/agent/agent.go @@ -32,6 +32,9 @@ type Config struct { ProviderID string ModelID string Instructions string + // Task is the first user message. If it is empty, the run sends the + // CLI's own task message from BuildPrompt. + Task string } // ConfigSource gives the agent configuration for a task. diff --git a/internal/setup/agent/agent_test.go b/internal/setup/agent/agent_test.go index b1a407f3..deac69f3 100644 --- a/internal/setup/agent/agent_test.go +++ b/internal/setup/agent/agent_test.go @@ -42,6 +42,17 @@ func TestCredentialEnvVar(t *testing.T) { assert.Equal(t, MobileKeyEnvVar, CredentialEnvVar("swift-client-sdk")) } +func TestBuildFacts_GivesFactsAndNoTask(t *testing.T) { + facts := BuildFacts(Task{SDKID: "python-server-sdk", Language: "Python", EntryPoint: "main.py", FlagKey: "my-new-flag", SDKInstalled: true}) + + assert.Contains(t, facts, "- SDK: python-server-sdk") + assert.Contains(t, facts, "- Entry point: main.py") + assert.Contains(t, facts, "- SDK package installed: yes") + assert.Contains(t, facts, "- Flag created by setup: my-new-flag") + assert.Contains(t, facts, "- Environment variable for the SDK credential: LAUNCHDARKLY_SDK_KEY") + assert.NotContains(t, facts, "Evaluate") +} + func TestBuildPrompt_NoEntryPoint_AsksTheAgentToFindOne(t *testing.T) { prompt := BuildPrompt(Task{SDKID: "go-server-sdk", FlagKey: "f"}) diff --git a/internal/setup/agent/aiconfig.go b/internal/setup/agent/aiconfig.go new file mode 100644 index 00000000..12e2197f --- /dev/null +++ b/internal/setup/agent/aiconfig.go @@ -0,0 +1,341 @@ +package agent + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "strings" + "sync" + "time" + + "github.com/launchdarkly/go-ai-sdk/aiserver" + "github.com/launchdarkly/go-sdk-common/v3/ldcontext" + ld "github.com/launchdarkly/go-server-sdk/v7" + "github.com/launchdarkly/go-server-sdk/v7/interfaces" + "github.com/launchdarkly/go-server-sdk/v7/ldcomponents" +) + +// SetupAgentConfigKey is the key of the setup-agent AI Config. +const SetupAgentConfigKey = "ldcli-setup-agent" + +const ( + contextKind = "ldcli-setup" + initTimeout = 5 * time.Second + // environmentIDEnv is the only setting that the AI SDK reads from the + // environment and that InitOptions cannot set. + environmentIDEnv = "LD_ENVIRONMENT_ID" + // RelayEnvironmentIDHeader is the response header in which the relay sends + // the client-side ID of the environment that it forwards to. The AI SDK + // sends that ID with its events and spans, so AgentControl can link runs + // to the config's Monitoring tab. The ID is not a secret, and the relay + // knows it because it holds that environment's SDK key. Like + // RelayTokenHeader, the name does not start with X-LD-. + RelayEnvironmentIDHeader = "LD-Setup-Agent-Environment-ID" +) + +// AIConfigSource reads the setup-agent AI Config with the LaunchDarkly AI SDK. +// It evaluates the config locally with the detected facts as context +// attributes, so targeting can give each kind of project its own +// instructions and model. +// +// The SDK reaches the config through the relay in gonfalon, which holds the +// real SDK key. The SDK gets a placeholder key, and a loopback proxy adds the +// user's access token to each request. So no SDK key is on the user's +// machine. +// +// If the SDK cannot start, for example because the relay refuses the request, +// or the config is missing or off, it returns the configuration of Fallback. +// Setup must not fail because the config is unavailable. +type AIConfigSource struct { + Key string + // RelayBaseURI is the user's base URI. The relay is at RelayPath under it. + RelayBaseURI string + AccessToken string + Fallback ConfigSource + + // checkRelay, initClient, and inspect are fields so that tests can replace + // them. + checkRelay func(ctx context.Context, pollingBase string) (string, error) + initClient func(ctx context.Context, opts aiserver.InitOptions) error + inspect func(ctx context.Context, key string, c ldcontext.Context) aiserver.InspectResult + + once sync.Once + initErr error + proxy *relayProxy + // started reports that this source started the SDK's global client, so + // Close must stop it. + started bool +} + +// NewAIConfigSource returns an AIConfigSource for a logged-in user, and +// DefaultConfigSource if there is no access token. +func NewAIConfigSource(baseURI, accessToken string) ConfigSource { + if strings.TrimSpace(accessToken) == "" || strings.TrimSpace(baseURI) == "" { + return DefaultConfigSource{} + } + return &AIConfigSource{ + Key: SetupAgentConfigKey, + RelayBaseURI: baseURI, + AccessToken: accessToken, + Fallback: DefaultConfigSource{}, + } +} + +// relayOptions sets every connection option of the AI SDK to the loopback +// proxy. The SDK fills an empty option from an environment variable, such as +// LD_BASE_URI or OTEL_EXPORTER_OTLP_ENDPOINT. ldcli uses LD_BASE_URI for its own +// API address, so an empty option would connect the SDK to the wrong place. +// +// The paths under the proxy match the relay routes: the SDK adds its own +// paths, such as /all for the stream and /v1/traces for spans. +func relayOptions(proxyBase string) aiserver.InitOptions { + return aiserver.InitOptions{ + SDKKey: RelayPlaceholderKey, + BaseURI: proxyBase + "/polling", + StreamURI: proxyBase + "/stream", + EventsURI: proxyBase + "/events", + OTLPEndpoint: proxyBase + "/otel", + ServiceName: "ldcli", + Environment: "ldcli-setup", + WaitFor: initTimeout, + } +} + +func (s *AIConfigSource) start(ctx context.Context) error { + proxy, err := startRelayProxy(s.RelayBaseURI, s.AccessToken) + if err != nil { + return err + } + s.proxy = proxy + opts := relayOptions(proxy.baseURL) + check := s.checkRelay + if check == nil { + check = checkRelay + } + environmentID, err := check(ctx, opts.BaseURI) + if err != nil { + return err + } + setEnvironmentID(environmentID) + if s.initClient != nil { + return s.initClient(ctx, opts) + } + if err := initQuietClient(ctx, opts); err != nil { + return err + } + s.started = true + return nil +} + +// setEnvironmentID sets LD_ENVIRONMENT_ID for this process, and clears it when +// the relay sent no ID, so a value in the user's shell for their own app does +// not reach the SDK. OpenCode does not get the variable, because the CLI +// removes LD_ variables from OpenCode's environment. +func setEnvironmentID(id string) { + if id == "" { + _ = os.Unsetenv(environmentIDEnv) + return + } + _ = os.Setenv(environmentIDEnv, id) +} + +// initQuietClient starts the AI SDK with a LaunchDarkly client that does not +// log. The SDK's own client logs to the terminal, which breaks the wizard's +// full-screen view, and InitOptions cannot turn its logs off. +// +// The client polls and does not open the flag stream. Setup reads the config +// once per run, so one polling request is all it needs, and it does not +// depend on a long-lived connection through the relay and the load balancer. +func initQuietClient(ctx context.Context, opts aiserver.InitOptions) error { + aiserver.SetLogger(slog.New(slog.NewTextHandler(io.Discard, nil))) + client, err := ld.MakeCustomClient(opts.SDKKey, ld.Config{ + ServiceEndpoints: interfaces.ServiceEndpoints{ + Polling: opts.BaseURI, + Streaming: opts.StreamURI, + Events: opts.EventsURI, + }, + DataSource: ldcomponents.PollingDataSource(), + Logging: ldcomponents.NoLogging(), + DiagnosticOptOut: true, + }, opts.WaitFor) + if err != nil { + if client != nil { + _ = client.Close() + } + return err + } + if _, err := aiserver.InitWithClient(ctx, client, opts); err != nil { + _ = client.Close() + return err + } + return nil +} + +func (s *AIConfigSource) Resolve(ctx context.Context, task Task) (Config, error) { + s.once.Do(func() { + s.initErr = s.start(ctx) + if s.initErr != nil { + s.closeProxy(ctx) + } + }) + if s.initErr != nil { + return s.fallback(ctx, task) + } + + inspect := s.inspect + if inspect == nil { + inspect = aiserver.InspectConfig + } + key := s.Key + if key == "" { + key = SetupAgentConfigKey + } + res := inspect(ctx, key, setupContext(task)) + if !res.Enabled || res.Config == nil { + return s.fallback(ctx, task) + } + return configFromAI(res.Config, task), nil +} + +func (s *AIConfigSource) fallback(ctx context.Context, task Task) (Config, error) { + if s.Fallback == nil { + return DefaultConfigSource{}.Resolve(ctx, task) + } + return s.Fallback.Resolve(ctx, task) +} + +// Close flushes the SDK events through the relay, stops the SDK client, and +// then stops the proxy. +func (s *AIConfigSource) Close(ctx context.Context) error { + var err error + if s.started { + err = aiserver.Shutdown(ctx) + s.started = false + } + s.closeProxy(ctx) + return err +} + +func (s *AIConfigSource) closeProxy(ctx context.Context) { + if s.proxy != nil { + _ = s.proxy.close(ctx) + s.proxy = nil + } +} + +// setupContext is the evaluation context for one run. Its attributes are the +// detected facts that targeting rules can match. It has no file paths and no +// names from the project, because the context goes to LaunchDarkly in +// analytics events. +func setupContext(t Task) ldcontext.Context { + key, err := randomHex(16) + if err != nil { + key = "ldcli-setup" + } + return ldcontext.NewBuilder(key). + Kind(contextKind). + Anonymous(true). + SetString("sdkId", t.SDKID). + SetString("language", t.Language). + SetString("framework", t.Framework). + SetString("packageManager", t.PackageManager). + SetBool("hasEntryPoint", t.EntryPoint != ""). + SetBool("sdkInstalled", t.SDKInstalled). + Build() +} + +// openCodeProviders maps AI Config provider names to OpenCode provider IDs +// where the two are different. +var openCodeProviders = map[string]string{ + "bedrock": "amazon-bedrock", + "gemini": "google", +} + +// configFromAI turns an AI Config variation into an agent configuration. An +// agent-mode config has instructions. A completion-mode config has messages, +// and its system messages become the instructions. +// +// The config's instructions decide the task. The first user message gives +// only the facts from setup. If the variation has no instructions, the +// built-in instructions and the CLI's own task message apply. +func configFromAI(c *aiserver.AiConfig, task Task) Config { + cfg := Config{Instructions: c.Instructions, ModelID: c.Model.Name} + if cfg.Instructions == "" { + var system []string + for _, m := range c.Messages { + if m.Role == "system" && m.Text != "" { + system = append(system, m.Text) + } + } + cfg.Instructions = strings.Join(system, "\n\n") + } + if cfg.Instructions == "" { + cfg.Instructions = DefaultInstructions + } else { + cfg.Task = BuildFacts(task) + } + provider := strings.ToLower(strings.TrimSpace(c.Provider.Name)) + if mapped, ok := openCodeProviders[provider]; ok { + provider = mapped + } + cfg.ProviderID = provider + // OpenCode needs both values to pick a model. With only one, OpenCode + // uses its default model. + if cfg.ProviderID == "" || cfg.ModelID == "" { + cfg.ProviderID, cfg.ModelID = "", "" + } + return cfg +} + +// RelayError is a refusal from the relay, for example because the kill switch +// is off or the access token is not valid. +type RelayError struct { + Status int + Code string + Message string +} + +func (e *RelayError) Error() string { + if e.Message != "" { + return fmt.Sprintf("the setup agent service refused the request (%s): %s", e.Code, e.Message) + } + if e.Code != "" { + return fmt.Sprintf("the setup agent service refused the request (%s)", e.Code) + } + return fmt.Sprintf("the setup agent service returned HTTP %d", e.Status) +} + +// checkRelay makes one polling request through the proxy before the SDK +// starts. The Go server SDK retries a refused stream until its start timeout +// ends, so without this check a refusal would delay every setup run. It +// returns the environment ID that the relay sends, or an empty string. +func checkRelay(ctx context.Context, pollingBase string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, initTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, pollingBase+"/sdk/latest-all", nil) + if err != nil { + return "", err + } + req.Header.Set("Authorization", RelayPlaceholderKey) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("could not reach the setup agent service: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 300 { + return strings.TrimSpace(resp.Header.Get(RelayEnvironmentIDHeader)), nil + } + refusal := &RelayError{Status: resp.StatusCode} + var body struct { + Code string `json:"code"` + Message string `json:"message"` + } + if json.NewDecoder(io.LimitReader(resp.Body, 4096)).Decode(&body) == nil { + refusal.Code, refusal.Message = body.Code, body.Message + } + return "", refusal +} diff --git a/internal/setup/agent/aiconfig_test.go b/internal/setup/agent/aiconfig_test.go new file mode 100644 index 00000000..d6c56a69 --- /dev/null +++ b/internal/setup/agent/aiconfig_test.go @@ -0,0 +1,202 @@ +package agent + +import ( + "context" + "errors" + "os" + "strings" + "testing" + + "github.com/launchdarkly/go-ai-sdk/aiserver" + "github.com/launchdarkly/go-sdk-common/v3/ldcontext" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRelayOptions_SetsEveryConnectionOption(t *testing.T) { + opts := relayOptions("http://127.0.0.1:5000/secret") + + assert.Equal(t, aiserver.InitOptions{ + SDKKey: RelayPlaceholderKey, + BaseURI: "http://127.0.0.1:5000/secret/polling", + StreamURI: "http://127.0.0.1:5000/secret/stream", + EventsURI: "http://127.0.0.1:5000/secret/events", + OTLPEndpoint: "http://127.0.0.1:5000/secret/otel", + ServiceName: "ldcli", + Environment: "ldcli-setup", + WaitFor: initTimeout, + }, opts, "an empty option would make the SDK read an LD_ variable") +} + +func TestNewAIConfigSource(t *testing.T) { + assert.Equal(t, DefaultConfigSource{}, NewAIConfigSource("https://app.launchdarkly.com", ""), "no login, no AI Config") + + source, ok := NewAIConfigSource("https://app.launchdarkly.com", "api-123").(*AIConfigSource) + require.True(t, ok) + assert.Equal(t, SetupAgentConfigKey, source.Key) + assert.Equal(t, "https://app.launchdarkly.com", source.RelayBaseURI) + assert.Equal(t, "api-123", source.AccessToken) +} + +func TestAIConfigSource_StartsTheSDKBehindTheProxy(t *testing.T) { + t.Setenv(environmentIDEnv, "the-users-own-app") + var got aiserver.InitOptions + s := &AIConfigSource{ + RelayBaseURI: "https://app.launchdarkly.com", + AccessToken: "api-123", + checkRelay: func(context.Context, string) (string, error) { return "catfood-client-id", nil }, + initClient: func(_ context.Context, opts aiserver.InitOptions) error { + got = opts + return nil + }, + inspect: func(context.Context, string, ldcontext.Context) aiserver.InspectResult { + return aiserver.InspectResult{} + }, + } + defer s.Close(context.Background()) + + _, err := s.Resolve(context.Background(), Task{}) + + require.NoError(t, err) + assert.Equal(t, RelayPlaceholderKey, got.SDKKey) + assert.True(t, strings.HasPrefix(got.StreamURI, "http://127.0.0.1:"), got.StreamURI) + assert.NotContains(t, got.StreamURI, "api-123", "the token is never in an SDK option") + assert.Equal(t, "catfood-client-id", os.Getenv(environmentIDEnv), "the ID comes from the relay") +} + +func TestAIConfigSource_NoEnvironmentID_ClearsTheUsersValue(t *testing.T) { + t.Setenv(environmentIDEnv, "the-users-own-app") + s, _ := fakeSource(nil, aiserver.InspectResult{}) + defer s.Close(context.Background()) + + _, _ = s.Resolve(context.Background(), Task{}) + + _, set := os.LookupEnv(environmentIDEnv) + assert.False(t, set) +} + +type inspectCall struct { + key string + ctx ldcontext.Context +} + +func fakeSource(initErr error, res aiserver.InspectResult) (*AIConfigSource, *[]inspectCall) { + var calls []inspectCall + s := &AIConfigSource{ + Key: "setup", + RelayBaseURI: "https://app.launchdarkly.com", + AccessToken: "api-123", + checkRelay: noRelayCheck, + initClient: func(context.Context, aiserver.InitOptions) error { return initErr }, + inspect: func(_ context.Context, key string, c ldcontext.Context) aiserver.InspectResult { + calls = append(calls, inspectCall{key: key, ctx: c}) + return res + }, + } + return s, &calls +} + +func enabled(c aiserver.AiConfig) aiserver.InspectResult { + return aiserver.InspectResult{Enabled: true, Config: &c} +} + +func agentConfig(provider, model, instructions string) aiserver.AiConfig { + c := aiserver.AiConfig{Instructions: instructions, Model: aiserver.Model{Name: model}} + c.Provider.Name = provider + return c +} + +func TestAIConfigSource_AgentMode(t *testing.T) { + s, calls := fakeSource(nil, enabled(agentConfig("anthropic", "claude-sonnet-5", "Edit the entry point."))) + + cfg, err := s.Resolve(context.Background(), Task{SDKID: "node-server", Language: "JavaScript", Framework: "nextjs", EntryPoint: "app/layout.tsx", Dir: "/home/me/secret-project"}) + + require.NoError(t, err) + assert.Equal(t, "anthropic", cfg.ProviderID) + assert.Equal(t, "claude-sonnet-5", cfg.ModelID) + assert.Equal(t, "Edit the entry point.", cfg.Instructions, "the config's instructions are the system prompt") + assert.Contains(t, cfg.Task, "Follow your instructions", "the first message gives facts, not a task") + assert.Contains(t, cfg.Task, "- SDK: node-server") + assert.NotContains(t, cfg.Task, "Evaluate the boolean flag", "the CLI's own task is not sent") + require.Len(t, *calls, 1) + call := (*calls)[0] + assert.Equal(t, "setup", call.key) + assert.Equal(t, ldcontext.Kind("ldcli-setup"), call.ctx.Kind()) + assert.True(t, call.ctx.Anonymous()) + assert.Equal(t, "nextjs", call.ctx.GetValue("framework").StringValue()) + assert.Equal(t, "node-server", call.ctx.GetValue("sdkId").StringValue()) + assert.True(t, call.ctx.GetValue("hasEntryPoint").BoolValue()) + for _, name := range call.ctx.GetOptionalAttributeNames(nil) { + value := call.ctx.GetValue(name).StringValue() + assert.NotContains(t, value, "secret-project", "the context must not carry paths") + assert.NotContains(t, value, "layout.tsx", "the context must not carry paths") + } +} + +func TestAIConfigSource_CompletionModeUsesSystemMessages(t *testing.T) { + c := agentConfig("openai", "gpt-6", "") + c.Messages = []aiserver.Message{{Role: "system", Text: "One"}, {Role: "user", Text: "skip"}, {Role: "system", Text: "Two"}} + s, _ := fakeSource(nil, enabled(c)) + + cfg, err := s.Resolve(context.Background(), Task{}) + + require.NoError(t, err) + assert.Equal(t, "One\n\nTwo", cfg.Instructions) +} + +func TestAIConfigSource_MapsProviderNames(t *testing.T) { + s, _ := fakeSource(nil, enabled(agentConfig("Bedrock", "anthropic.claude", "x"))) + + cfg, err := s.Resolve(context.Background(), Task{}) + + require.NoError(t, err) + assert.Equal(t, "amazon-bedrock", cfg.ProviderID) +} + +func TestAIConfigSource_ModelWithoutProvider_UsesOpenCodeDefault(t *testing.T) { + s, _ := fakeSource(nil, enabled(agentConfig("", "claude-sonnet-5", ""))) + + cfg, err := s.Resolve(context.Background(), Task{}) + + require.NoError(t, err) + assert.Equal(t, Config{Instructions: DefaultInstructions}, cfg, "no instructions: the built-in task applies") +} + +func TestAIConfigSource_FallsBack(t *testing.T) { + for name, s := range map[string]*AIConfigSource{ + "init fails": first(fakeSource(errors.New("no network"), aiserver.InspectResult{})), + "config is off": first(fakeSource(nil, aiserver.InspectResult{Enabled: false})), + "config missing": first(fakeSource(nil, aiserver.InspectResult{Enabled: true})), + } { + t.Run(name, func(t *testing.T) { + cfg, err := s.Resolve(context.Background(), Task{}) + + require.NoError(t, err) + assert.Equal(t, Config{Instructions: DefaultInstructions}, cfg) + }) + } +} + +func TestAIConfigSource_InitsOnce(t *testing.T) { + inits := 0 + s := &AIConfigSource{ + RelayBaseURI: "https://app.launchdarkly.com", + checkRelay: noRelayCheck, + initClient: func(context.Context, aiserver.InitOptions) error { inits++; return nil }, + inspect: func(context.Context, string, ldcontext.Context) aiserver.InspectResult { + return aiserver.InspectResult{} + }, + } + + _, _ = s.Resolve(context.Background(), Task{}) + _, _ = s.Resolve(context.Background(), Task{}) + + assert.Equal(t, 1, inits) + assert.NoError(t, s.Close(context.Background()), "a source that did not start the SDK has nothing to stop") +} + +func first(s *AIConfigSource, _ *[]inspectCall) *AIConfigSource { return s } + +// noRelayCheck stands in for the request to the relay, so unit tests make no +// network calls. +func noRelayCheck(context.Context, string) (string, error) { return "", nil } diff --git a/internal/setup/agent/opencode.go b/internal/setup/agent/opencode.go index 824e76ca..ed494583 100644 --- a/internal/setup/agent/opencode.go +++ b/internal/setup/agent/opencode.go @@ -86,7 +86,11 @@ func (o *OpenCode) Start(ctx context.Context, task Task) (Run, error) { return nil, err } - run, err := startRun(ctx, srv.client, cfg, BuildPrompt(task)) + prompt := cfg.Task + if prompt == "" { + prompt = BuildPrompt(task) + } + run, err := startRun(ctx, srv.client, cfg, prompt) if err != nil { srv.stop() return nil, err diff --git a/internal/setup/agent/prompt.go b/internal/setup/agent/prompt.go index d50968a1..09f3b04a 100644 --- a/internal/setup/agent/prompt.go +++ b/internal/setup/agent/prompt.go @@ -70,6 +70,29 @@ Follow these rules: - Follow the code style of the project. - When you finish, list the files that you changed and say in one sentence how the user can see the flag value.` +// BuildFacts returns the first user message when the instructions come from +// the AI Config. It gives only the facts that the CLI detected and the choices +// that it made earlier in setup. It gives no task, so the config's +// instructions decide what the agent does. +func BuildFacts(t Task) string { + var b strings.Builder + b.WriteString("Follow your instructions to set up LaunchDarkly in the project in the current directory.\n\n") + b.WriteString("Facts from ldcli setup:\n") + writeFact(&b, "SDK", t.SDKID) + writeFact(&b, "Language", t.Language) + writeFact(&b, "Framework", t.Framework) + writeFact(&b, "Package manager", t.PackageManager) + writeFact(&b, "Entry point", t.EntryPoint) + installed := "no" + if t.SDKInstalled { + installed = "yes" + } + writeFact(&b, "SDK package installed", installed) + writeFact(&b, "Flag created by setup", t.FlagKey) + writeFact(&b, "Environment variable for the SDK credential", CredentialEnvVar(t.SDKID)) + return b.String() +} + // BuildPrompt returns the task message for one run. func BuildPrompt(t Task) string { var b strings.Builder diff --git a/internal/setup/agent/relay_proxy.go b/internal/setup/agent/relay_proxy.go new file mode 100644 index 00000000..433ece89 --- /dev/null +++ b/internal/setup/agent/relay_proxy.go @@ -0,0 +1,107 @@ +package agent + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "net/http/httputil" + "net/url" + "strings" + "time" +) + +const ( + // RelayPath is the path of the SDK relay in gonfalon, under the user's base + // URI. + RelayPath = "/internal/ldcli-setup-agent/sdk" + // RelayTokenHeader carries the user's access token to the relay. The relay + // cannot use Authorization, because the SDK puts its key there. The name + // must not start with X-LD-: gonfalon removes client request headers with + // that prefix, because its private auth headers use it. + RelayTokenHeader = "LD-CLI-Token" + // RelayPlaceholderKey is the SDK key that the CLI gives the AI SDK. The + // relay replaces it with the real key on the way to catfood. + RelayPlaceholderKey = "ldcli-placeholder" +) + +// relayProxy is a loopback HTTP server between the AI SDK and the relay. It +// adds the access token to each request, so the token is never in an SDK +// option, an environment variable, or the OpenCode process. The AI SDK cannot +// add a header to its trace exports, which is the other reason for the proxy. +// +// The proxy accepts requests only under a random path, so another process on +// the machine, such as OpenCode, cannot use it to call the relay with the +// user's token. +type relayProxy struct { + server *http.Server + listener net.Listener + // baseURL is the proxy address with the random path, for example + // http://127.0.0.1:52011/3f9a.... + baseURL string +} + +// startRelayProxy starts the proxy. relayBase is the user's base URI, for +// example https://app.launchdarkly.com. +func startRelayProxy(relayBase, accessToken string) (*relayProxy, error) { + target, err := url.Parse(strings.TrimRight(relayBase, "/") + RelayPath) + if err != nil || target.Scheme == "" || target.Host == "" { + return nil, fmt.Errorf("the base URI %q is not a valid URL", relayBase) + } + secret, err := randomHex(16) + if err != nil { + return nil, err + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, fmt.Errorf("could not start the relay proxy: %w", err) + } + + prefix := "/" + secret + proxy := &httputil.ReverseProxy{ + Rewrite: func(r *httputil.ProxyRequest) { + rest := strings.TrimPrefix(r.In.URL.Path, prefix) + r.Out.URL.Scheme = target.Scheme + r.Out.URL.Host = target.Host + r.Out.URL.Path = target.Path + rest + r.Out.URL.RawPath = "" + r.Out.Host = target.Host + r.Out.Header.Set(RelayTokenHeader, accessToken) + }, + // Send each part of a response as it arrives. The flag stream is a + // long-lived event stream, and a buffer would hold its events back. + FlushInterval: -1, + } + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != prefix && !strings.HasPrefix(r.URL.Path, prefix+"/") { + http.NotFound(w, r) + return + } + proxy.ServeHTTP(w, r) + }) + + p := &relayProxy{ + server: &http.Server{ + Handler: handler, + ReadHeaderTimeout: 10 * time.Second, + }, + listener: listener, + baseURL: "http://" + listener.Addr().String() + prefix, + } + go func() { + if err := p.server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { + _ = listener.Close() + } + }() + return p, nil +} + +// close stops the proxy and ends open connections, including the flag +// stream. +func (p *relayProxy) close(ctx context.Context) error { + if err := p.server.Shutdown(ctx); err != nil { + return p.server.Close() + } + return nil +} diff --git a/internal/setup/agent/relay_proxy_test.go b/internal/setup/agent/relay_proxy_test.go new file mode 100644 index 00000000..11bbc0ae --- /dev/null +++ b/internal/setup/agent/relay_proxy_test.go @@ -0,0 +1,100 @@ +package agent + +import ( + "bufio" + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeRelay records the last request and answers like the relay. +func fakeRelay(t *testing.T) (*httptest.Server, *http.Request) { + t.Helper() + got := &http.Request{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + *got = *r.Clone(context.Background()) + if strings.HasSuffix(r.URL.Path, "/stream/all") { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, "event: put\ndata: {}\n\n") + w.(http.Flusher).Flush() + <-r.Context().Done() + return + } + _, _ = io.WriteString(w, `{"flags":{}}`) + })) + t.Cleanup(srv.Close) + return srv, got +} + +func startTestProxy(t *testing.T, relayBase string) *relayProxy { + t.Helper() + p, err := startRelayProxy(relayBase, "api-secret-token") + require.NoError(t, err) + t.Cleanup(func() { _ = p.close(context.Background()) }) + return p +} + +func TestRelayProxy_AddsTheTokenAndMapsThePath(t *testing.T) { + relay, got := fakeRelay(t) + p := startTestProxy(t, relay.URL+"/") + + req, err := http.NewRequest(http.MethodGet, p.baseURL+"/polling/sdk/latest-all?basis=1", nil) + require.NoError(t, err) + req.Header.Set("Authorization", RelayPlaceholderKey) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, `{"flags":{}}`, string(body)) + assert.Equal(t, RelayPath+"/polling/sdk/latest-all", got.URL.Path) + assert.Equal(t, "basis=1", got.URL.RawQuery) + assert.Equal(t, "api-secret-token", got.Header.Get(RelayTokenHeader)) + assert.Equal(t, RelayPlaceholderKey, got.Header.Get("Authorization")) +} + +func TestRelayProxy_RefusesRequestsWithoutTheSecretPath(t *testing.T) { + relay, got := fakeRelay(t) + p := startTestProxy(t, relay.URL) + host := strings.TrimSuffix(p.baseURL, p.baseURL[strings.LastIndex(p.baseURL, "/"):]) + + resp, err := http.Get(host + "/polling/sdk/latest-all") + require.NoError(t, err) + resp.Body.Close() + + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + assert.Empty(t, got.Header.Get(RelayTokenHeader), "a request without the secret never reaches the relay") +} + +func TestRelayProxy_StreamsWithoutBuffering(t *testing.T) { + relay, _ := fakeRelay(t) + p := startTestProxy(t, relay.URL) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.baseURL+"/stream/all", nil) + require.NoError(t, err) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + // The relay holds the stream open, so this line arrives only if the proxy + // sends it on at once. + line, err := bufio.NewReader(resp.Body).ReadString('\n') + require.NoError(t, err) + assert.Equal(t, "event: put\n", line) +} + +func TestStartRelayProxy_RejectsAnInvalidBaseURI(t *testing.T) { + _, err := startRelayProxy("not a url", "token") + + assert.Error(t, err) +} diff --git a/internal/setup/agent/relay_sdk_test.go b/internal/setup/agent/relay_sdk_test.go new file mode 100644 index 00000000..f8ebd8cd --- /dev/null +++ b/internal/setup/agent/relay_sdk_test.go @@ -0,0 +1,114 @@ +package agent + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// setupAgentFlag is the flag behind an agent-mode AI Config, as the stream +// sends it. +const setupAgentFlag = `{"key":"ldcli-setup-agent","on":true,"version":1,"salt":"s", +"fallthrough":{"variation":0},"offVariation":0, +"variations":[{"_ldMeta":{"enabled":true,"variationKey":"v1","mode":"agent","version":1}, +"model":{"name":"claude-sonnet-5"},"provider":{"name":"anthropic"}, +"instructions":"From AgentControl."}]}` + +// TestAIConfigSource_RealSDKThroughTheProxy starts the real AI SDK through the +// loopback proxy against a fake relay. It checks that the SDK uses the relay +// paths, sends the placeholder key and the token, and reads the config. +func TestAIConfigSource_RealSDKThroughTheProxy(t *testing.T) { + var mu sync.Mutex + var paths []string + relay := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + paths = append(paths, r.Method+" "+r.URL.Path) + mu.Unlock() + if r.Header.Get(RelayTokenHeader) != "api-123" || r.Header.Get("Authorization") != RelayPlaceholderKey { + w.WriteHeader(http.StatusUnauthorized) + return + } + if r.URL.Path == RelayPath+"/polling/sdk/latest-all" { + w.Header().Set("Content-Type", "application/json") + w.Header().Set(RelayEnvironmentIDHeader, "catfood-client-id") + fmt.Fprintf(w, `{"flags":{"ldcli-setup-agent":%s},"segments":{}}`, strings.ReplaceAll(setupAgentFlag, "\n", "")) + return + } + _, _ = io.Copy(io.Discard, r.Body) + w.WriteHeader(http.StatusAccepted) + })) + defer relay.Close() + t.Setenv(environmentIDEnv, "the-users-own-app") + + source := NewAIConfigSource(relay.URL, "api-123") + closer := source.(*AIConfigSource) + defer func() { _ = closer.Close(context.Background()) }() + + cfg, err := source.Resolve(context.Background(), Task{SDKID: "node-server"}) + + require.NoError(t, err) + assert.Equal(t, "anthropic/claude-sonnet-5", cfg.ProviderID+"/"+cfg.ModelID) + assert.Equal(t, "From AgentControl.", cfg.Instructions) + assert.Equal(t, "catfood-client-id", os.Getenv(environmentIDEnv), "the ID from the relay header reaches the SDK") + require.NoError(t, closer.Close(context.Background())) + mu.Lock() + defer mu.Unlock() + for _, p := range paths { + assert.Contains(t, p, RelayPath+"/", "every request goes to a relay route") + } + joined := strings.Join(paths, "\n") + assert.Contains(t, joined, "GET "+RelayPath+"/polling/sdk/latest-all", "the SDK polls") + assert.NotContains(t, joined, RelayPath+"/stream/", "the SDK does not open the flag stream") + t.Logf("requests: %v", paths) +} + +// TestAIConfigSource_RelayRefuses_FallsBack checks that a refusal, such as the +// kill switch being off, ends setup's wait at once and uses the built-in +// configuration. +func TestAIConfigSource_RelayRefuses_FallsBack(t *testing.T) { + relay := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = io.WriteString(w, `{"code":"ldcli_setup_agent_disabled"}`) + })) + defer relay.Close() + + source := NewAIConfigSource(relay.URL, "api-123").(*AIConfigSource) + defer func() { _ = source.Close(context.Background()) }() + + start := time.Now() + cfg, err := source.Resolve(context.Background(), Task{}) + + require.NoError(t, err) + assert.Equal(t, Config{Instructions: DefaultInstructions}, cfg) + assert.Less(t, time.Since(start), time.Second, "a refusal does not wait for the SDK start timeout") + var refusal *RelayError + require.ErrorAs(t, source.initErr, &refusal) + assert.Equal(t, "ldcli_setup_agent_disabled", refusal.Code) +} + +func TestAIConfigSource_RelayUnreachable_FallsBack(t *testing.T) { + relay := httptest.NewServer(http.NotFoundHandler()) + url := relay.URL + relay.Close() + + source := NewAIConfigSource(url, "api-123").(*AIConfigSource) + defer func() { _ = source.Close(context.Background()) }() + + start := time.Now() + cfg, err := source.Resolve(context.Background(), Task{}) + + require.NoError(t, err) + assert.Equal(t, Config{Instructions: DefaultInstructions}, cfg) + assert.Less(t, time.Since(start), time.Second) +}