From 8db491c6e5a6928c8208632398a4aa16e213312d Mon Sep 17 00:00:00 2001 From: Anthony Torns II Date: Sun, 18 Oct 2026 20:05:07 -0700 Subject: [PATCH 01/16] build: upgrade to Go 1.26 and golangci-lint v2 The Go AI SDK needs Go 1.26 and go-server-sdk v7.19.1. This change moves the module to Go 1.26 and go-server-sdk from v7.13.4 to v7.19.1. golangci-lint v1.63.4 cannot read the type data of Go 1.26, so the pre-commit hook moves to v2.11.4. The new .golangci.yml keeps the v1 rules: staticcheck runs only the SA and S1 checks, and unchecked Close and print calls are not errors. The hook now pins Go 1.26.0. Without the pin, pre-commit downloads the newest Go, and the linter cannot read the type data of a newer release. Co-Authored-By: Claude Opus 5.5 --- .golangci.yml | 15 +++++++++++++++ .pre-commit-config.yaml | 7 +++++-- AGENTS.md | 2 +- go.mod | 16 ++++++++-------- go.sum | 30 ++++++++++++++++-------------- 5 files changed, 45 insertions(+), 25 deletions(-) create mode 100644 .golangci.yml diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 000000000..d2f7a62ea --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,15 @@ +version: "2" + +linters: + settings: + staticcheck: + # The v1 defaults: the staticcheck (SA) and gosimple (S1) checks. v2 adds + # the stylecheck (ST) and quickfix (QF) checks to staticcheck. + checks: + - "SA*" + - "S1*" + exclusions: + # The v1 default exclusions: unchecked Close, Flush, and print calls are + # not errors here. + presets: + - std-error-handling diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 55acc6463..36b8485a0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,11 +1,14 @@ -# Cache bust: force rebuild for Go 1.26.0 compatibility repos: - repo: https://github.com/golangci/golangci-lint - rev: v1.63.4 + rev: v2.11.4 hooks: - id: golangci-lint entry: golangci-lint run language: golang + # Build and run the linter with the Go version in go.mod. Without a pin, + # pre-commit downloads the newest Go, and the linter cannot read the type + # data of a Go release newer than itself. + language_version: 1.26.0 require_serial: true types: [go] - repo: https://github.com/pre-commit/pre-commit-hooks diff --git a/AGENTS.md b/AGENTS.md index 8e6787a2c..9646d891a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -132,5 +132,5 @@ Installed via `make install-hooks`. Checks: ## Linting -- Go: `golangci-lint` (v1.63.4) via pre-commit +- Go: `golangci-lint` (v2.11.4) via pre-commit, configured in `.golangci.yml` - Frontend: ESLint + Prettier diff --git a/go.mod b/go.mod index 67106fb30..3050676cd 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/launchdarkly/ldcli -go 1.25 +go 1.26 require ( github.com/adrg/frontmatter v0.2.0 @@ -27,8 +27,8 @@ 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-sdk-common/v3 v3.4.0 - github.com/launchdarkly/go-server-sdk/v7 v7.13.4 + 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 github.com/mattn/go-sqlite3 v1.14.52 github.com/mitchellh/go-homedir v1.1.0 @@ -88,11 +88,11 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/launchdarkly/ccache v1.1.0 // indirect - github.com/launchdarkly/eventsource v1.10.0 // indirect - github.com/launchdarkly/go-jsonstream/v3 v3.1.0 // indirect - github.com/launchdarkly/go-sdk-events/v3 v3.5.0 // indirect + github.com/launchdarkly/eventsource v1.14.0 // indirect + github.com/launchdarkly/go-jsonstream/v3 v3.1.2 // indirect + github.com/launchdarkly/go-sdk-events/v3 v3.7.0 // indirect github.com/launchdarkly/go-semver v1.0.3 // indirect - github.com/launchdarkly/go-server-sdk-evaluation/v3 v3.0.1 // indirect + github.com/launchdarkly/go-server-sdk-evaluation/v3 v3.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mailru/easyjson v0.9.1 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -126,7 +126,7 @@ require ( 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.41.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 gopkg.in/validator.v2 v2.0.1 // indirect diff --git a/go.sum b/go.sum index 8215df15d..70f7d3dd9 100644 --- a/go.sum +++ b/go.sum @@ -189,20 +189,20 @@ github.com/launchdarkly/api-client-go/v24 v24.0.0 h1:QgWQ0aJhu2SSP9+jbPgYyxDbzNV github.com/launchdarkly/api-client-go/v24 v24.0.0/go.mod h1:hdrIs4v0BW+/hQY/jCtNM+kT77uND8ahZa8500U180c= github.com/launchdarkly/ccache v1.1.0 h1:voD1M+ZJXR3MREOKtBwgTF9hYHl1jg+vFKS/+VAkR2k= github.com/launchdarkly/ccache v1.1.0/go.mod h1:TlxzrlnzvYeXiLHmesMuvoZetu4Z97cV1SsdqqBJi1Q= -github.com/launchdarkly/eventsource v1.10.0 h1:H9Tp6AfGu/G2qzBJC26iperrvwhzdbiA/gx7qE2nDFI= -github.com/launchdarkly/eventsource v1.10.0/go.mod h1:J3oa50bPvJesZqNAJtb5btSIo5N6roDWhiAS3IpsKck= -github.com/launchdarkly/go-jsonstream/v3 v3.1.0 h1:U/7/LplZO72XefBQ+FzHf6o4FwLHVqBE+4V58Ornu/E= -github.com/launchdarkly/go-jsonstream/v3 v3.1.0/go.mod h1:2Pt4BR5AwWgsuVTCcIpB6Os04JFIKWfoA+7faKkZB5E= -github.com/launchdarkly/go-sdk-common/v3 v3.4.0 h1:GTRulE0G43xdWY1QdjAXJ7QnZ8PMFU8pOWZICCydEtM= -github.com/launchdarkly/go-sdk-common/v3 v3.4.0/go.mod h1:6MNeeP8b2VtsM6I3TbShCHW/+tYh2c+p5dB+ilS69sg= -github.com/launchdarkly/go-sdk-events/v3 v3.5.0 h1:Yav8Thm70dZbO8U1foYwZPf3w60n/lNBRaYeeNM/qg4= -github.com/launchdarkly/go-sdk-events/v3 v3.5.0/go.mod h1:oepYWQ2RvvjfL2WxkE1uJJIuRsIMOP4WIVgUpXRPcNI= +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-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= +github.com/launchdarkly/go-sdk-common/v3 v3.6.0/go.mod h1:HUhSIcObNywBajTAiYT/U/llpHzGL7HMdVVBm7Nyy+E= +github.com/launchdarkly/go-sdk-events/v3 v3.7.0 h1:EuyvvZqpSE7kaMl2o2Am0KgpsfNB7KMJD9O+Ko1ESw4= +github.com/launchdarkly/go-sdk-events/v3 v3.7.0/go.mod h1:dmhlNVS8zxMyjvQE1eOHMP/rKz0fKtiWn53/Ol+S+IE= github.com/launchdarkly/go-semver v1.0.3 h1:agIy/RN3SqeQDIfKkl+oFslEdeIs7pgsJBs3CdCcGQM= github.com/launchdarkly/go-semver v1.0.3/go.mod h1:xFmMwXba5Mb+3h72Z+VeSs9ahCvKo2QFUTHRNHVqR28= -github.com/launchdarkly/go-server-sdk-evaluation/v3 v3.0.1 h1:rTgcYAFraGFj7sBMB2b7JCYCm0b9kph4FaMX02t4osQ= -github.com/launchdarkly/go-server-sdk-evaluation/v3 v3.0.1/go.mod h1:fPS5d+zOsgFnMunj+Ki6jjlZtFvo4h9iNbtNXxzYn58= -github.com/launchdarkly/go-server-sdk/v7 v7.13.4 h1:Jn4HQDkmV0DhbUKLz7gFbNrhVrE3xSx8D6FTKEDheis= -github.com/launchdarkly/go-server-sdk/v7 v7.13.4/go.mod h1:EEUSX/bc1mVq+3pwrRzTfu8LFRWRI1UL4XMgzsKWmbE= +github.com/launchdarkly/go-server-sdk-evaluation/v3 v3.2.0 h1:yT3skSXnTMPvcIt/Oz0LubiU9emv9V5xrAZvFt71bH8= +github.com/launchdarkly/go-server-sdk-evaluation/v3 v3.2.0/go.mod h1:TlNMB48dwNz+sewt+zsCrboZLMJj4Dov8Msg/cLv5GM= +github.com/launchdarkly/go-server-sdk/v7 v7.19.1 h1:GNeZM9H51BNOlyWtyFouu3r+GWP/Fz94HgufCxawF2M= +github.com/launchdarkly/go-server-sdk/v7 v7.19.1/go.mod h1:0Ne1xHskoelCQ/RCNVzkyjCr06yhtR1PclQ0owGriUM= github.com/launchdarkly/go-test-helpers/v3 v3.1.0 h1:E3bxJMzMoA+cJSF3xxtk2/chr1zshl1ZWa0/oR+8bvg= github.com/launchdarkly/go-test-helpers/v3 v3.1.0/go.mod h1:Ake5+hZFS/DmIGKx/cizhn5W9pGA7pplcR7xCxWiLIo= github.com/launchdarkly/sdk-meta/api v0.7.5 h1:56pC/y94azZuD6iiuYK76+mzY8AZFqpTsNP8JsNnzts= @@ -370,8 +370,8 @@ 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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +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/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= @@ -404,6 +404,8 @@ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/ghodss/yaml.v1 v1.0.0 h1:JlY4R6oVz+ZSvcDhVfNQ/k/8Xo6yb2s1PBhslPZPX4c= +gopkg.in/ghodss/yaml.v1 v1.0.0/go.mod h1:HDvRMPQLqycKPs9nWLuzZWxsxRzISLCRORiDpBUOMqg= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/validator.v2 v2.0.1 h1:xF0KWyGWXm/LM2G1TrEjqOu4pa6coO9AlWSf3msVfDY= From e4a4d2b799e80b1f24f1c712823f3e1210a4b3e4 Mon Sep 17 00:00:00 2001 From: Anthony Torns II Date: Sun, 18 Oct 2026 20:11:10 -0700 Subject: [PATCH 02/16] feat(setup): read the setup agent config with the Go AI SDK The setup agent can now get its instructions and model from the ldcli-setup-agent AI Config. The CLI evaluates the config locally with the Go AI SDK. The detected facts are the context attributes, so targeting can give each kind of project its own instructions and model. The relay does not exist yet, so the source is opt-in for development. If LDCLI_SETUP_AGENT_SDK_KEY is set, the CLI uses the AI Config. If not, it uses the built-in configuration. If the SDK cannot start, or the config is off or missing, the CLI also uses the built-in configuration. The CLI sets every connection option of the SDK. The SDK fills an empty option from an environment variable such as LD_BASE_URI, and ldcli uses that variable for its own API address. The SDK is pinned to commit 6152dc9, because it has no release yet. Co-Authored-By: Claude Opus 5.5 --- cmd/setup/setup.go | 30 +++- go.mod | 81 ++++++---- go.sum | 180 ++++++++++++++-------- internal/setup/agent/aiconfig.go | 214 ++++++++++++++++++++++++++ internal/setup/agent/aiconfig_test.go | 166 ++++++++++++++++++++ 5 files changed, 570 insertions(+), 101 deletions(-) create mode 100644 internal/setup/agent/aiconfig.go create mode 100644 internal/setup/agent/aiconfig_test.go diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index 8fe4ae053..8a25db7df 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" @@ -27,6 +29,11 @@ func NewSetupCmd( Initializer: setup.Initializer{}, } runner := agent.NewOpenCode() + // With LDCLI_SETUP_AGENT_SDK_KEY set, the agent reads its instructions and + // model from the setup-agent AI Config. Without it, the built-in + // configuration applies. + source := agent.ConfigSourceFromEnv() + runner.Config = source cmd := &cobra.Command{ Use: "setup", Short: "Set up LaunchDarkly in your project", @@ -51,14 +58,33 @@ initialization code if OpenCode is installed, and the built-in templates if not. viper.GetBool(cliflags.AnalyticsOptOut), ).SendCommandRunEvent(cmdAnalytics.CmdRunEventProperties(cmd, "setup", nil)) }, - RunE: runSetupWizard(analyticsTrackerFn, svc, runner), + RunE: closingSource(source, runSetupWizard(analyticsTrackerFn, svc, runner)), } cmd.Flags().String(agentFlag, "auto", "Use the setup agent to add initialization code: auto, on, or off") cmd.AddCommand(newDetectCmd(svc)) cmd.AddCommand(newInstallCmd(svc)) cmd.AddCommand(newInitCmd(svc)) - cmd.AddCommand(newAgentCmd(svc, runner)) + agentCmd := newAgentCmd(svc, runner) + agentCmd.RunE = closingSource(source, agentCmd.RunE) + cmd.AddCommand(agentCmd) return cmd } + +// closingSource closes the config source after the command runs, so the AI +// SDK sends its events before ldcli exits. +func closingSource(source agent.ConfigSource, run func(*cobra.Command, []string) error) func(*cobra.Command, []string) error { + closer, ok := source.(interface{ Close(context.Context) error }) + if !ok { + return run + } + return func(cmd *cobra.Command, args []string) error { + 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 3050676cd..cd695c6d8 100644 --- a/go.mod +++ b/go.mod @@ -1,16 +1,16 @@ module github.com/launchdarkly/ldcli -go 1.26 +go 1.26.0 require ( github.com/adrg/frontmatter v0.2.0 github.com/adrg/xdg v0.5.3 github.com/atotto/clipboard v0.1.4 - github.com/aws/aws-sdk-go-v2 v1.47.0 - github.com/aws/aws-sdk-go-v2/config v1.33.5 + github.com/aws/aws-sdk-go-v2 v1.47.3 + github.com/aws/aws-sdk-go-v2/config v1.33.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.0 + github.com/aws/aws-sdk-go-v2/service/sts v1.51.4 github.com/blacktop/go-dwarf v1.0.14 github.com/blacktop/go-macho v1.1.282 github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 @@ -27,6 +27,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 @@ -34,7 +35,7 @@ require ( github.com/mitchellh/go-homedir v1.1.0 github.com/muesli/reflow v0.3.0 github.com/oapi-codegen/oapi-codegen/v2 v2.7.1 - github.com/oapi-codegen/runtime v1.1.2 + github.com/oapi-codegen/runtime v1.6.0 github.com/pelletier/go-toml/v2 v2.2.4 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pkg/errors v0.9.1 @@ -43,10 +44,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.5.2 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 ) @@ -54,37 +55,40 @@ 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/internal/configsources v1.5.3 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.3 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.3 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.3 // 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/smithy-go v1.28.1 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.22 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.20.9 // 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.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 @@ -116,19 +120,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 70f7d3dd9..58438c46d 100644 --- a/go.sum +++ b/go.sum @@ -18,40 +18,40 @@ github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7D github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aws/aws-sdk-go-v2 v1.47.0 h1:0jsHallhJCeaU0Ko48c/3FK1ctOQ7NpzggxriJOQ8MQ= -github.com/aws/aws-sdk-go-v2 v1.47.0/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU= -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/internal/configsources v1.5.3 h1:Hp/VgjP0BysR3OgLlR057Vz2LcbbVnoWeJ+3qWiS/fY= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.3/go.mod h1:nwGV5qw7F1IZPgxCvA/ph8N2TAuz+BkRG/bXn808qMA= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.3 h1:MUaM4f+kj1ZIBPZfUS8cxP1GKXXZtHJjAthy93AN7SM= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.3/go.mod h1:6YmVmEVRI5ZZzRjCSsb9SryKH0hAlMRdgA7kG9aDvBU= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.3 h1:fuSCw4Z2qfRCztMPO3GXJNSiEp6Wee+WOLwrHHUMy9c= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.3/go.mod h1:6SxcHheD1pPR5+kWm1wGvjlL/YqUsh267sAfEmN4K7A= +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.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= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.6/go.mod h1:ONzdPVYC95oBOu0VWNEsKEntcoemP3xVKjT+qWml1s4= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.6 h1:Qmn60RWuk9+bd2ddvJJqwf3Cpac3VvGMOdbGemtUhX4= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.6/go.mod h1:h/YJhe4AVyQxEUMluph10iqV7mjajH7A9L+BHF5ppis= github.com/aws/aws-sdk-go-v2/service/devopsagent v1.17.0 h1:nVr3wL92fkSof1T+blrzUEKJzWa1Kpp9zBhTKG9Wa5c= github.com/aws/aws-sdk-go-v2/service/devopsagent v1.17.0/go.mod h1:gT39kJ1GWdG7BOdHmzqOTe3lIaX70nJPQUNxd+nWnXU= github.com/aws/aws-sdk-go-v2/service/iam v1.64.0 h1:CjgiPuyW26WQeQ934DxMNuwkDg6+ziqgPN9lHQ5rwYA= github.com/aws/aws-sdk-go-v2/service/iam v1.64.0/go.mod h1:TI7OT5XlpXfs4BM4Y5NXcHTiyBwkqv6NpGiM4/usn5A= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.3 h1:bON1rJf67TSTDCKg816AAIE4xSTtoo9tl0XRkO72R+I= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.3/go.mod h1:c5BBpjJcQXpfeq9iASyVKA3T6vX6B6LEXY4mL/gklDY= -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/sts v1.51.0 h1:Zpnqa6XtrNzXZnwbdCqHOXpXhMsa01ql/pcRQ1sb4hk= -github.com/aws/aws-sdk-go-v2/service/sts v1.51.0/go.mod h1:/8JRcdTt//hG0Q4BTmGbuOplT7ABe+5rdtqUHqXvYIM= -github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ= -github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.21 h1:e6YW5uME30TkrElR+4/xcY7NB6CCj2dcv7vtmGXhfhQ= +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.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= +github.com/aws/smithy-go v1.28.5/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= @@ -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.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= @@ -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.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 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= @@ -237,10 +248,12 @@ github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3 github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs= +github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY= github.com/oapi-codegen/oapi-codegen/v2 v2.7.1 h1:a7Ab7YlpqkVG5HKrTaeFstm32Z5QOnyjnbsCO0jiMYM= github.com/oapi-codegen/oapi-codegen/v2 v2.7.1/go.mod h1:qzFy6iuobJw/hD1aRILee4G87/ShmhR0xYCwcUtZMCw= -github.com/oapi-codegen/runtime v1.1.2 h1:P2+CubHq8fO4Q6fV1tqDBZHCwpVpvPg7oKiYzQgXIyI= -github.com/oapi-codegen/runtime v1.1.2/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= +github.com/oapi-codegen/runtime v1.6.0 h1:7Xx+GlueD6nRuyKoCPzL434Jfi3BetbiJOrzCHp/VPU= +github.com/oapi-codegen/runtime v1.6.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY= github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU= github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw= @@ -273,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= @@ -305,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= @@ -327,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.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= -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= @@ -346,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= @@ -370,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= @@ -399,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/aiconfig.go b/internal/setup/agent/aiconfig.go new file mode 100644 index 000000000..2b475bf72 --- /dev/null +++ b/internal/setup/agent/aiconfig.go @@ -0,0 +1,214 @@ +package agent + +import ( + "context" + "os" + "strings" + "sync" + "time" + + "github.com/launchdarkly/go-ai-sdk/aiserver" + "github.com/launchdarkly/go-sdk-common/v3/ldcontext" +) + +// SetupAgentConfigKey is the key of the setup-agent AI Config. +const SetupAgentConfigKey = "ldcli-setup-agent" + +// Environment variables for development against catfood. They are temporary: +// the relay in gonfalon will replace them, and then no SDK key is on the +// user's machine. They do not start with LD_, because ldcli and the AI SDK +// both read LD_ variables for other purposes. +const ( + // SDKKeyEnv turns on the AI Config source. Its value is the SDK key of the + // environment that holds the setup-agent AI Config. + SDKKeyEnv = "LDCLI_SETUP_AGENT_SDK_KEY" + // BaseURIEnv is the app URL of the LaunchDarkly instance that holds the AI + // Config. The default is catfood. + BaseURIEnv = "LDCLI_SETUP_AGENT_BASE_URI" + // OTLPEndpointEnv is the trace endpoint. The default is the AI SDK default. + OTLPEndpointEnv = "LDCLI_SETUP_AGENT_OTLP_ENDPOINT" +) + +const ( + defaultSetupAgentBaseURI = "https://app.ld.catamorphic.com" + contextKind = "ldcli-setup" + initTimeout = 5 * time.Second +) + +// 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. +// +// If the SDK cannot start, 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 + Options aiserver.InitOptions + Fallback ConfigSource + + // initClient and inspect are fields so that tests can replace them. + 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 + // started reports that this source started the SDK's global client, so + // Close must stop it. + started bool +} + +// ConfigSourceFromEnv returns an AIConfigSource if SDKKeyEnv is set, and +// DefaultConfigSource if not. +func ConfigSourceFromEnv() ConfigSource { + key := strings.TrimSpace(os.Getenv(SDKKeyEnv)) + if key == "" { + return DefaultConfigSource{} + } + return &AIConfigSource{ + Key: SetupAgentConfigKey, + Options: initOptions(key, + strings.TrimSpace(os.Getenv(BaseURIEnv)), + strings.TrimSpace(os.Getenv(OTLPEndpointEnv))), + Fallback: DefaultConfigSource{}, + } +} + +// initOptions sets every connection option of the AI SDK. 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. +func initOptions(sdkKey, baseURI, otlpEndpoint string) aiserver.InitOptions { + if baseURI == "" { + baseURI = defaultSetupAgentBaseURI + } + baseURI = strings.TrimRight(baseURI, "/") + if otlpEndpoint == "" { + otlpEndpoint = aiserver.DefaultOTLPEndpoint + } + return aiserver.InitOptions{ + SDKKey: sdkKey, + BaseURI: baseURI, + StreamURI: serviceURI(baseURI, "stream"), + EventsURI: serviceURI(baseURI, "events"), + OTLPEndpoint: otlpEndpoint, + ServiceName: "ldcli", + Environment: "ldcli-setup", + WaitFor: initTimeout, + } +} + +// serviceURI derives a service address from the app address of a LaunchDarkly +// instance. An instance serves the SDK streams and events on subdomains named +// for the service, for example https://stream.ld.catamorphic.com for +// https://app.ld.catamorphic.com. An address that does not start with app. is +// used as it is. +func serviceURI(baseURI, service string) string { + scheme, host, ok := strings.Cut(baseURI, "://") + if !ok || !strings.HasPrefix(host, "app.") { + return baseURI + } + return scheme + "://" + service + "." + strings.TrimPrefix(host, "app.") +} + +func (s *AIConfigSource) Resolve(ctx context.Context, task Task) (Config, error) { + s.once.Do(func() { + if s.initClient != nil { + s.initErr = s.initClient(ctx, s.Options) + return + } + _, s.initErr = aiserver.InitClient(ctx, s.Options) + s.started = s.initErr == nil + }) + 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), 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 and stops the SDK client. +func (s *AIConfigSource) Close(ctx context.Context) error { + if !s.started { + return nil + } + return aiserver.Shutdown(ctx) +} + +// 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. +func configFromAI(c *aiserver.AiConfig) 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 + } + 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 +} diff --git a/internal/setup/agent/aiconfig_test.go b/internal/setup/agent/aiconfig_test.go new file mode 100644 index 000000000..c41ab2ec7 --- /dev/null +++ b/internal/setup/agent/aiconfig_test.go @@ -0,0 +1,166 @@ +package agent + +import ( + "context" + "errors" + "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 TestInitOptions_SetsEveryConnectionOption(t *testing.T) { + // These are what the SDK reads when an option is empty. None of them may + // reach it. + t.Setenv("LD_SDK_KEY", "sdk-wrong") + t.Setenv("LD_BASE_URI", "https://wrong.example.com") + t.Setenv("LD_STREAM_URI", "https://wrong.example.com") + t.Setenv("LD_EVENTS_URI", "https://wrong.example.com") + t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "https://wrong.example.com") + + opts := initOptions("sdk-test", "", "") + + assert.Equal(t, aiserver.InitOptions{ + SDKKey: "sdk-test", + BaseURI: "https://app.ld.catamorphic.com", + StreamURI: "https://stream.ld.catamorphic.com", + EventsURI: "https://events.ld.catamorphic.com", + OTLPEndpoint: aiserver.DefaultOTLPEndpoint, + ServiceName: "ldcli", + Environment: "ldcli-setup", + WaitFor: initTimeout, + }, opts) +} + +func TestServiceURI(t *testing.T) { + assert.Equal(t, "https://stream.launchdarkly.com", serviceURI("https://app.launchdarkly.com", "stream")) + assert.Equal(t, "https://events.eu.launchdarkly.com", serviceURI("https://app.eu.launchdarkly.com", "events")) + assert.Equal(t, "http://localhost:8765", serviceURI("http://localhost:8765", "stream"), "an address without app. is used as it is") +} + +func TestConfigSourceFromEnv(t *testing.T) { + t.Setenv(SDKKeyEnv, "") + assert.Equal(t, DefaultConfigSource{}, ConfigSourceFromEnv()) + + t.Setenv(SDKKeyEnv, "sdk-test") + t.Setenv(BaseURIEnv, "https://app.example.com/") + source, ok := ConfigSourceFromEnv().(*AIConfigSource) + require.True(t, ok) + assert.Equal(t, SetupAgentConfigKey, source.Key) + assert.Equal(t, "https://app.example.com", source.Options.BaseURI) + assert.Equal(t, "https://stream.example.com", source.Options.StreamURI) +} + +type inspectCall struct { + key string + ctx ldcontext.Context +} + +func fakeSource(initErr error, res aiserver.InspectResult) (*AIConfigSource, *[]inspectCall) { + var calls []inspectCall + s := &AIConfigSource{ + Key: "setup", + 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, Config{ProviderID: "anthropic", ModelID: "claude-sonnet-5", Instructions: "Edit the entry point."}, cfg) + 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) +} + +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{ + 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 } From cfbc3eca6918f347762f1f836acd52e60df6a35c Mon Sep 17 00:00:00 2001 From: Anthony Torns II Date: Mon, 19 Oct 2026 13:41:36 -0700 Subject: [PATCH 03/16] feat(setup): reach the setup agent config through the gonfalon relay The AI SDK now reads the setup-agent AI Config through the relay in gonfalon, so no SDK key is on the user's machine. This replaces the LDCLI_SETUP_AGENT_* variables. The SDK gets the placeholder key ldcli-placeholder. A loopback proxy in the CLI adds the user's access token in X-LD-CLI-Token and forwards each request to /internal/ldcli-setup-agent/sdk under the user's base URI. The proxy accepts requests only under a random path, so OpenCode cannot use it. The proxy is also needed because the SDK cannot add a header to its trace exports. The CLI makes one polling request before the SDK starts. The Go server SDK retries a refused stream until its start timeout ends, so without the check a refusal would delay setup by 5 seconds. The CLI creates the SDK's LaunchDarkly client itself, with logging off, because the SDK logs to the terminal and breaks the wizard's view. The CLI also sets LD_ENVIRONMENT_ID for its own process, so a value in the user's shell does not reach the SDK. Co-Authored-By: Claude Opus 5.5 --- cmd/setup/setup.go | 39 ++-- internal/setup/agent/aiconfig.go | 247 ++++++++++++++++------- internal/setup/agent/aiconfig_test.go | 91 ++++++--- internal/setup/agent/relay_proxy.go | 105 ++++++++++ internal/setup/agent/relay_proxy_test.go | 100 +++++++++ internal/setup/agent/relay_sdk_test.go | 110 ++++++++++ 6 files changed, 570 insertions(+), 122 deletions(-) create mode 100644 internal/setup/agent/relay_proxy.go create mode 100644 internal/setup/agent/relay_proxy_test.go create mode 100644 internal/setup/agent/relay_sdk_test.go diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index 8a25db7df..91b34d06b 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -29,11 +29,6 @@ func NewSetupCmd( Initializer: setup.Initializer{}, } runner := agent.NewOpenCode() - // With LDCLI_SETUP_AGENT_SDK_KEY set, the agent reads its instructions and - // model from the setup-agent AI Config. Without it, the built-in - // configuration applies. - source := agent.ConfigSourceFromEnv() - runner.Config = source cmd := &cobra.Command{ Use: "setup", Short: "Set up LaunchDarkly in your project", @@ -58,7 +53,7 @@ initialization code if OpenCode is installed, and the built-in templates if not. viper.GetBool(cliflags.AnalyticsOptOut), ).SendCommandRunEvent(cmdAnalytics.CmdRunEventProperties(cmd, "setup", nil)) }, - RunE: closingSource(source, runSetupWizard(analyticsTrackerFn, svc, runner)), + RunE: withConfigSource(runner, runSetupWizard(analyticsTrackerFn, svc, runner)), } cmd.Flags().String(agentFlag, "auto", "Use the setup agent to add initialization code: auto, on, or off") @@ -66,25 +61,31 @@ initialization code if OpenCode is installed, and the built-in templates if not. cmd.AddCommand(newInstallCmd(svc)) cmd.AddCommand(newInitCmd(svc)) agentCmd := newAgentCmd(svc, runner) - agentCmd.RunE = closingSource(source, agentCmd.RunE) + agentCmd.RunE = withConfigSource(runner, agentCmd.RunE) cmd.AddCommand(agentCmd) return cmd } -// closingSource closes the config source after the command runs, so the AI -// SDK sends its events before ldcli exits. -func closingSource(source agent.ConfigSource, run func(*cobra.Command, []string) error) func(*cobra.Command, []string) error { - closer, ok := source.(interface{ Close(context.Context) error }) - if !ok { - return run - } +// 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 { - defer func() { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - _ = closer.Close(ctx) - }() + 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/internal/setup/agent/aiconfig.go b/internal/setup/agent/aiconfig.go index 2b475bf72..70b50e9b6 100644 --- a/internal/setup/agent/aiconfig.go +++ b/internal/setup/agent/aiconfig.go @@ -2,6 +2,11 @@ package agent import ( "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" "os" "strings" "sync" @@ -9,30 +14,26 @@ import ( "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" -// Environment variables for development against catfood. They are temporary: -// the relay in gonfalon will replace them, and then no SDK key is on the -// user's machine. They do not start with LD_, because ldcli and the AI SDK -// both read LD_ variables for other purposes. -const ( - // SDKKeyEnv turns on the AI Config source. Its value is the SDK key of the - // environment that holds the setup-agent AI Config. - SDKKeyEnv = "LDCLI_SETUP_AGENT_SDK_KEY" - // BaseURIEnv is the app URL of the LaunchDarkly instance that holds the AI - // Config. The default is catfood. - BaseURIEnv = "LDCLI_SETUP_AGENT_BASE_URI" - // OTLPEndpointEnv is the trace endpoint. The default is the AI SDK default. - OTLPEndpointEnv = "LDCLI_SETUP_AGENT_OTLP_ENDPOINT" -) +// SetupAgentEnvironmentID is the client-side ID of the catfood environment that +// holds the setup-agent AI Config. The AI SDK sends it with its events and +// spans, so AgentControl can link runs to the config's Monitoring tab. It is +// not a secret. If it is empty, the SDK sends no environment ID. +var SetupAgentEnvironmentID = "" const ( - defaultSetupAgentBaseURI = "https://app.ld.catamorphic.com" - contextKind = "ldcli-setup" - initTimeout = 5 * time.Second + 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" ) // AIConfigSource reads the setup-agent AI Config with the LaunchDarkly AI SDK. @@ -40,13 +41,23 @@ const ( // attributes, so targeting can give each kind of project its own // instructions and model. // -// If the SDK cannot start, or the config is missing or off, it returns the -// configuration of Fallback. Setup must not fail because the config is -// unavailable. +// 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 - Options aiserver.InitOptions - Fallback ConfigSource + Key string + // RelayBaseURI is the user's base URI. The relay is at RelayPath under it. + RelayBaseURI string + AccessToken string + // EnvironmentID is the client-side ID of the environment that holds the + // config. + EnvironmentID string + Fallback ConfigSource // initClient and inspect are fields so that tests can replace them. initClient func(ctx context.Context, opts aiserver.InitOptions) error @@ -54,72 +65,109 @@ type AIConfigSource struct { 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 } -// ConfigSourceFromEnv returns an AIConfigSource if SDKKeyEnv is set, and -// DefaultConfigSource if not. -func ConfigSourceFromEnv() ConfigSource { - key := strings.TrimSpace(os.Getenv(SDKKeyEnv)) - if key == "" { +// 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, - Options: initOptions(key, - strings.TrimSpace(os.Getenv(BaseURIEnv)), - strings.TrimSpace(os.Getenv(OTLPEndpointEnv))), - Fallback: DefaultConfigSource{}, + Key: SetupAgentConfigKey, + RelayBaseURI: baseURI, + AccessToken: accessToken, + EnvironmentID: SetupAgentEnvironmentID, + Fallback: DefaultConfigSource{}, } } -// initOptions sets every connection option of the AI SDK. 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. -func initOptions(sdkKey, baseURI, otlpEndpoint string) aiserver.InitOptions { - if baseURI == "" { - baseURI = defaultSetupAgentBaseURI - } - baseURI = strings.TrimRight(baseURI, "/") - if otlpEndpoint == "" { - otlpEndpoint = aiserver.DefaultOTLPEndpoint - } +// 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: sdkKey, - BaseURI: baseURI, - StreamURI: serviceURI(baseURI, "stream"), - EventsURI: serviceURI(baseURI, "events"), - OTLPEndpoint: otlpEndpoint, + SDKKey: RelayPlaceholderKey, + BaseURI: proxyBase + "/polling", + StreamURI: proxyBase + "/stream", + EventsURI: proxyBase + "/events", + OTLPEndpoint: proxyBase + "/otel", ServiceName: "ldcli", Environment: "ldcli-setup", WaitFor: initTimeout, } } -// serviceURI derives a service address from the app address of a LaunchDarkly -// instance. An instance serves the SDK streams and events on subdomains named -// for the service, for example https://stream.ld.catamorphic.com for -// https://app.ld.catamorphic.com. An address that does not start with app. is -// used as it is. -func serviceURI(baseURI, service string) string { - scheme, host, ok := strings.Cut(baseURI, "://") - if !ok || !strings.HasPrefix(host, "app.") { - return baseURI - } - return scheme + "://" + service + "." + strings.TrimPrefix(host, "app.") +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) + // Set the variable for this process even when the ID is empty, so a value + // in the user's shell for their own app does not reach the SDK. OpenCode + // does not get it, because the CLI removes LD_ variables from OpenCode's + // environment. + if s.EnvironmentID == "" { + _ = os.Unsetenv(environmentIDEnv) + } else { + _ = os.Setenv(environmentIDEnv, s.EnvironmentID) + } + if s.initClient != nil { + return s.initClient(ctx, opts) + } + if err := initQuietClient(ctx, opts); err != nil { + return err + } + s.started = true + return nil +} + +// 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. +func initQuietClient(ctx context.Context, opts aiserver.InitOptions) error { + if err := checkRelay(ctx, opts.BaseURI); err != nil { + return err + } + 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, + }, + 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() { - if s.initClient != nil { - s.initErr = s.initClient(ctx, s.Options) - return + s.initErr = s.start(ctx) + if s.initErr != nil { + s.closeProxy(ctx) } - _, s.initErr = aiserver.InitClient(ctx, s.Options) - s.started = s.initErr == nil }) if s.initErr != nil { return s.fallback(ctx, task) @@ -147,12 +195,23 @@ func (s *AIConfigSource) fallback(ctx context.Context, task Task) (Config, error return s.Fallback.Resolve(ctx, task) } -// Close flushes the SDK events and stops the SDK client. +// 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 { - if !s.started { - return nil + 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 } - return aiserver.Shutdown(ctx) } // setupContext is the evaluation context for one run. Its attributes are the @@ -212,3 +271,51 @@ func configFromAI(c *aiserver.AiConfig) Config { } 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. +func checkRelay(ctx context.Context, pollingBase 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 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 index c41ab2ec7..34f0f9124 100644 --- a/internal/setup/agent/aiconfig_test.go +++ b/internal/setup/agent/aiconfig_test.go @@ -3,6 +3,8 @@ package agent import ( "context" "errors" + "os" + "strings" "testing" "github.com/launchdarkly/go-ai-sdk/aiserver" @@ -11,46 +13,66 @@ import ( "github.com/stretchr/testify/require" ) -func TestInitOptions_SetsEveryConnectionOption(t *testing.T) { - // These are what the SDK reads when an option is empty. None of them may - // reach it. - t.Setenv("LD_SDK_KEY", "sdk-wrong") - t.Setenv("LD_BASE_URI", "https://wrong.example.com") - t.Setenv("LD_STREAM_URI", "https://wrong.example.com") - t.Setenv("LD_EVENTS_URI", "https://wrong.example.com") - t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "https://wrong.example.com") - - opts := initOptions("sdk-test", "", "") +func TestRelayOptions_SetsEveryConnectionOption(t *testing.T) { + opts := relayOptions("http://127.0.0.1:5000/secret") assert.Equal(t, aiserver.InitOptions{ - SDKKey: "sdk-test", - BaseURI: "https://app.ld.catamorphic.com", - StreamURI: "https://stream.ld.catamorphic.com", - EventsURI: "https://events.ld.catamorphic.com", - OTLPEndpoint: aiserver.DefaultOTLPEndpoint, + 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) -} - -func TestServiceURI(t *testing.T) { - assert.Equal(t, "https://stream.launchdarkly.com", serviceURI("https://app.launchdarkly.com", "stream")) - assert.Equal(t, "https://events.eu.launchdarkly.com", serviceURI("https://app.eu.launchdarkly.com", "events")) - assert.Equal(t, "http://localhost:8765", serviceURI("http://localhost:8765", "stream"), "an address without app. is used as it is") + }, opts, "an empty option would make the SDK read an LD_ variable") } -func TestConfigSourceFromEnv(t *testing.T) { - t.Setenv(SDKKeyEnv, "") - assert.Equal(t, DefaultConfigSource{}, ConfigSourceFromEnv()) +func TestNewAIConfigSource(t *testing.T) { + assert.Equal(t, DefaultConfigSource{}, NewAIConfigSource("https://app.launchdarkly.com", ""), "no login, no AI Config") - t.Setenv(SDKKeyEnv, "sdk-test") - t.Setenv(BaseURIEnv, "https://app.example.com/") - source, ok := ConfigSourceFromEnv().(*AIConfigSource) + 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.example.com", source.Options.BaseURI) - assert.Equal(t, "https://stream.example.com", source.Options.StreamURI) + 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", + EnvironmentID: "catfood-client-id", + 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)) +} + +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 { @@ -61,8 +83,10 @@ type inspectCall struct { func fakeSource(initErr error, res aiserver.InspectResult) (*AIConfigSource, *[]inspectCall) { var calls []inspectCall s := &AIConfigSource{ - Key: "setup", - initClient: func(context.Context, aiserver.InitOptions) error { return initErr }, + Key: "setup", + RelayBaseURI: "https://app.launchdarkly.com", + AccessToken: "api-123", + 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 @@ -150,7 +174,8 @@ func TestAIConfigSource_FallsBack(t *testing.T) { func TestAIConfigSource_InitsOnce(t *testing.T) { inits := 0 s := &AIConfigSource{ - initClient: func(context.Context, aiserver.InitOptions) error { inits++; return nil }, + RelayBaseURI: "https://app.launchdarkly.com", + initClient: func(context.Context, aiserver.InitOptions) error { inits++; return nil }, inspect: func(context.Context, string, ldcontext.Context) aiserver.InspectResult { return aiserver.InspectResult{} }, diff --git a/internal/setup/agent/relay_proxy.go b/internal/setup/agent/relay_proxy.go new file mode 100644 index 000000000..3549e4af9 --- /dev/null +++ b/internal/setup/agent/relay_proxy.go @@ -0,0 +1,105 @@ +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. + RelayTokenHeader = "X-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 000000000..11bbc0ae5 --- /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 000000000..48867e977 --- /dev/null +++ b/internal/setup/agent/relay_sdk_test.go @@ -0,0 +1,110 @@ +package agent + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "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 strings.HasPrefix(r.URL.Path, RelayPath+"/stream/") { + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprintf(w, "event: put\ndata: {\"path\":\"/\",\"data\":{\"flags\":{\"ldcli-setup-agent\":%s},\"segments\":{}}}\n\n", + strings.ReplaceAll(setupAgentFlag, "\n", "")) + w.(http.Flusher).Flush() + <-r.Context().Done() + return + } + _, _ = io.Copy(io.Discard, r.Body) + w.WriteHeader(http.StatusAccepted) + })) + defer relay.Close() + + 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, Config{ProviderID: "anthropic", ModelID: "claude-sonnet-5", Instructions: "From AgentControl."}, cfg) + 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") + } + assert.Contains(t, strings.Join(paths, "\n"), "GET "+RelayPath+"/stream/all") + 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) +} From b4f7ace50658eab0509cc00acb093b38fa5369f2 Mon Sep 17 00:00:00 2001 From: Anthony Torns II Date: Mon, 19 Oct 2026 13:59:19 -0700 Subject: [PATCH 04/16] feat(setup): take the setup agent environment ID from the relay The CLI no longer hardcodes the client-side ID of the environment that holds the setup-agent AI Config. The relay sends it in the X-LD-Setup-Agent-Environment-ID response header, and the CLI reads it from the check that runs before the SDK starts. The relay holds that environment's SDK key, so it knows the ID. When the environment changes, only the gonfalon configuration changes. Co-Authored-By: Claude Opus 5.5 --- internal/setup/agent/aiconfig.go | 74 ++++++++++++++------------ internal/setup/agent/aiconfig_test.go | 14 +++-- internal/setup/agent/relay_sdk_test.go | 4 ++ 3 files changed, 55 insertions(+), 37 deletions(-) diff --git a/internal/setup/agent/aiconfig.go b/internal/setup/agent/aiconfig.go index 70b50e9b6..da24af2bd 100644 --- a/internal/setup/agent/aiconfig.go +++ b/internal/setup/agent/aiconfig.go @@ -22,18 +22,18 @@ import ( // SetupAgentConfigKey is the key of the setup-agent AI Config. const SetupAgentConfigKey = "ldcli-setup-agent" -// SetupAgentEnvironmentID is the client-side ID of the catfood environment that -// holds the setup-agent AI Config. The AI SDK sends it with its events and -// spans, so AgentControl can link runs to the config's Monitoring tab. It is -// not a secret. If it is empty, the SDK sends no environment ID. -var SetupAgentEnvironmentID = "" - 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. + RelayEnvironmentIDHeader = "X-LD-Setup-Agent-Environment-ID" ) // AIConfigSource reads the setup-agent AI Config with the LaunchDarkly AI SDK. @@ -54,12 +54,11 @@ type AIConfigSource struct { // RelayBaseURI is the user's base URI. The relay is at RelayPath under it. RelayBaseURI string AccessToken string - // EnvironmentID is the client-side ID of the environment that holds the - // config. - EnvironmentID string - Fallback ConfigSource + Fallback ConfigSource - // initClient and inspect are fields so that tests can replace them. + // 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 @@ -78,11 +77,10 @@ func NewAIConfigSource(baseURI, accessToken string) ConfigSource { return DefaultConfigSource{} } return &AIConfigSource{ - Key: SetupAgentConfigKey, - RelayBaseURI: baseURI, - AccessToken: accessToken, - EnvironmentID: SetupAgentEnvironmentID, - Fallback: DefaultConfigSource{}, + Key: SetupAgentConfigKey, + RelayBaseURI: baseURI, + AccessToken: accessToken, + Fallback: DefaultConfigSource{}, } } @@ -113,15 +111,15 @@ func (s *AIConfigSource) start(ctx context.Context) error { } s.proxy = proxy opts := relayOptions(proxy.baseURL) - // Set the variable for this process even when the ID is empty, so a value - // in the user's shell for their own app does not reach the SDK. OpenCode - // does not get it, because the CLI removes LD_ variables from OpenCode's - // environment. - if s.EnvironmentID == "" { - _ = os.Unsetenv(environmentIDEnv) - } else { - _ = os.Setenv(environmentIDEnv, s.EnvironmentID) + 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) } @@ -132,13 +130,22 @@ func (s *AIConfigSource) start(ctx context.Context) error { 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. func initQuietClient(ctx context.Context, opts aiserver.InitOptions) error { - if err := checkRelay(ctx, opts.BaseURI); err != nil { - return err - } aiserver.SetLogger(slog.New(slog.NewTextHandler(io.Discard, nil))) client, err := ld.MakeCustomClient(opts.SDKKey, ld.Config{ ServiceEndpoints: interfaces.ServiceEndpoints{ @@ -292,22 +299,23 @@ func (e *RelayError) Error() string { // 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. -func checkRelay(ctx context.Context, pollingBase string) error { +// 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 + 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) + return "", fmt.Errorf("could not reach the setup agent service: %w", err) } defer resp.Body.Close() if resp.StatusCode < 300 { - return nil + return strings.TrimSpace(resp.Header.Get(RelayEnvironmentIDHeader)), nil } refusal := &RelayError{Status: resp.StatusCode} var body struct { @@ -317,5 +325,5 @@ func checkRelay(ctx context.Context, pollingBase string) error { if json.NewDecoder(io.LimitReader(resp.Body, 4096)).Decode(&body) == nil { refusal.Code, refusal.Message = body.Code, body.Message } - return refusal + return "", refusal } diff --git a/internal/setup/agent/aiconfig_test.go b/internal/setup/agent/aiconfig_test.go index 34f0f9124..9b33c0deb 100644 --- a/internal/setup/agent/aiconfig_test.go +++ b/internal/setup/agent/aiconfig_test.go @@ -42,9 +42,9 @@ 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", - EnvironmentID: "catfood-client-id", + 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 @@ -61,7 +61,7 @@ func TestAIConfigSource_StartsTheSDKBehindTheProxy(t *testing.T) { 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)) + assert.Equal(t, "catfood-client-id", os.Getenv(environmentIDEnv), "the ID comes from the relay") } func TestAIConfigSource_NoEnvironmentID_ClearsTheUsersValue(t *testing.T) { @@ -86,6 +86,7 @@ func fakeSource(initErr error, res aiserver.InspectResult) (*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}) @@ -175,6 +176,7 @@ 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{} @@ -189,3 +191,7 @@ func TestAIConfigSource_InitsOnce(t *testing.T) { } 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/relay_sdk_test.go b/internal/setup/agent/relay_sdk_test.go index 48867e977..fda794d2b 100644 --- a/internal/setup/agent/relay_sdk_test.go +++ b/internal/setup/agent/relay_sdk_test.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/http/httptest" + "os" "strings" "sync" "testing" @@ -46,9 +47,11 @@ func TestAIConfigSource_RealSDKThroughTheProxy(t *testing.T) { return } _, _ = io.Copy(io.Discard, r.Body) + w.Header().Set(RelayEnvironmentIDHeader, "catfood-client-id") w.WriteHeader(http.StatusAccepted) })) defer relay.Close() + t.Setenv(environmentIDEnv, "the-users-own-app") source := NewAIConfigSource(relay.URL, "api-123") closer := source.(*AIConfigSource) @@ -58,6 +61,7 @@ func TestAIConfigSource_RealSDKThroughTheProxy(t *testing.T) { require.NoError(t, err) assert.Equal(t, Config{ProviderID: "anthropic", ModelID: "claude-sonnet-5", Instructions: "From AgentControl."}, cfg) + 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() From 78652458a472fa66a30c81796faa5185e9860076 Mon Sep 17 00:00:00 2001 From: Anthony Torns II Date: Mon, 19 Oct 2026 16:20:54 -0700 Subject: [PATCH 05/16] fix(setup): rename the relay headers so gonfalon does not strip them gonfalon removes client request headers that start with X-LD-, because its private auth headers use that prefix. So the relay never received X-LD-CLI-Token, and it refused every request with ldcli_token_invalid. The token header is now LD-CLI-Token, and the environment ID header is now LD-Setup-Agent-Environment-ID. Co-Authored-By: Claude Opus 5.5 --- internal/setup/agent/aiconfig.go | 5 +++-- internal/setup/agent/relay_proxy.go | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/internal/setup/agent/aiconfig.go b/internal/setup/agent/aiconfig.go index da24af2bd..cd9cbd024 100644 --- a/internal/setup/agent/aiconfig.go +++ b/internal/setup/agent/aiconfig.go @@ -32,8 +32,9 @@ const ( // 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. - RelayEnvironmentIDHeader = "X-LD-Setup-Agent-Environment-ID" + // 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. diff --git a/internal/setup/agent/relay_proxy.go b/internal/setup/agent/relay_proxy.go index 3549e4af9..433ece898 100644 --- a/internal/setup/agent/relay_proxy.go +++ b/internal/setup/agent/relay_proxy.go @@ -17,8 +17,10 @@ const ( // 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. - RelayTokenHeader = "X-LD-CLI-Token" + // 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" From 210557b7376bc0073dfe85b4284a7fe095c3adbd Mon Sep 17 00:00:00 2001 From: Anthony Torns II Date: Mon, 19 Oct 2026 16:36:02 -0700 Subject: [PATCH 06/16] fix(setup): queue the agent's permission requests OpenCode can ask for several tool calls at once. The wizard kept only one pending request, so each new request replaced the one before it. The replaced requests never got an answer, and OpenCode waited forever. The wizard now keeps a queue. It shows the oldest request and how many more are waiting, and each y or n answers one request. Co-Authored-By: Claude Opus 5.5 --- cmd/setup/agent_test.go | 26 +++++++++++++++++++++++++- cmd/setup/model.go | 10 ++++++---- cmd/setup/update.go | 10 +++++----- cmd/setup/view.go | 10 +++++++--- 4 files changed, 43 insertions(+), 13 deletions(-) diff --git a/cmd/setup/agent_test.go b/cmd/setup/agent_test.go index f0e56b7c8..1699d9567 100644 --- a/cmd/setup/agent_test.go +++ b/cmd/setup/agent_test.go @@ -175,7 +175,7 @@ func TestWizard_AgentPermission_YAllows(t *testing.T) { require.NotNil(t, cmd) m, _ = update(t, m, cmd()) - assert.Nil(t, m.agentPermission) + assert.Empty(t, m.agentPermissions) assert.Equal(t, []fakeReply{{id: "per1", allow: true}}, run.replies) } @@ -327,3 +327,27 @@ func TestStreamAgent_Error(t *testing.T) { func TestCredentialExports_SkipsEmptyValues(t *testing.T) { assert.Equal(t, []string{"export LAUNCHDARKLY_CLIENT_SIDE_ID=abc"}, credentialExports("", "abc", "")) } + +// OpenCode can ask for several tool calls at once. Each one must get its own +// answer, or OpenCode waits forever. +func TestWizard_AgentPermissions_AreQueued(t *testing.T) { + run := newFakeRun() + m := startedAgent(t, agentAuto, run) + for _, id := range []string{"per1", "per2", "per3"} { + m, _ = update(t, m, agentEventMsg{run: run, ev: agent.Event{Kind: agent.EventPermission, Permission: agent.PermissionRequest{ID: id, Permission: "bash", Patterns: []string{id}}}}) + } + view := m.View() + assert.Contains(t, view, "bash: per1", "the oldest request is shown first") + assert.Contains(t, view, "2 more waiting") + + for _, key := range []string{"y", "n", "y"} { + var cmd tea.Cmd + m, cmd = update(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}) + require.NotNil(t, cmd) + cmd() + } + + assert.Equal(t, []fakeReply{{id: "per1", allow: true}, {id: "per2", allow: false}, {id: "per3", allow: true}}, run.replies) + assert.Empty(t, m.agentPermissions) + assert.Contains(t, m.View(), "OpenCode is working") +} diff --git a/cmd/setup/model.go b/cmd/setup/model.go index bc324cf1c..6dbd4d41f 100644 --- a/cmd/setup/model.go +++ b/cmd/setup/model.go @@ -114,10 +114,12 @@ type wizardModel struct { agentLog []string // agentFiles are the files that the agent changed. agentFiles []string - // agentPermission is the request that waits for the user's answer. - agentPermission *agent.PermissionRequest - agentUsage *agent.Usage - agentDone bool + // agentPermissions are the requests that wait for the user's answer, oldest + // first. OpenCode can ask for several tool calls at once, and each one + // waits until it gets its own answer. + agentPermissions []agent.PermissionRequest + agentUsage *agent.Usage + agentDone bool // agentNote tells the user why setup used the templates after the agent // could not run. agentNote string diff --git a/cmd/setup/update.go b/cmd/setup/update.go index 30ab082fb..223fccc7b 100644 --- a/cmd/setup/update.go +++ b/cmd/setup/update.go @@ -51,9 +51,9 @@ func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.closeAgent() return m, tea.Quit case "y", "n": - if m.step == stepAgent && m.agentPermission != nil && m.agentRun != nil { - req := *m.agentPermission - m.agentPermission = nil + if m.step == stepAgent && len(m.agentPermissions) > 0 && m.agentRun != nil { + req := m.agentPermissions[0] + m.agentPermissions = m.agentPermissions[1:] return m, replyAgent(m.agentRun, req, msg.String() == "y") } case "left", "h": @@ -273,7 +273,7 @@ func (m wizardModel) handleAgentEvent(msg agentEventMsg) (tea.Model, tea.Cmd) { m.agentUsage = &u case agent.EventPermission: req := ev.Permission - m.agentPermission = &req + m.agentPermissions = append(m.agentPermissions, req) case agent.EventDone: m.agentDone = true m.closeAgent() @@ -296,7 +296,7 @@ func (m wizardModel) handleAgentEvent(msg agentEventMsg) (tea.Model, tea.Cmd) { func (m wizardModel) agentFailed(err error) (tea.Model, tea.Cmd) { m.closeAgent() m.agentRun = nil - m.agentPermission = nil + m.agentPermissions = nil if m.agentMode == agentOn || len(m.agentFiles) > 0 { m.err = errors.New("The setup agent failed: " + err.Error()) return m, nil diff --git a/cmd/setup/view.go b/cmd/setup/view.go index bb5e263d6..538d45157 100644 --- a/cmd/setup/view.go +++ b/cmd/setup/view.go @@ -430,10 +430,14 @@ func (m wizardModel) agentView() string { if len(log) > 0 { body += "\n" } - if m.agentPermission != nil { + if len(m.agentPermissions) > 0 { + hint := "y allow once · n reject" + if waiting := len(m.agentPermissions) - 1; waiting > 0 { + hint += fmt.Sprintf(" · %d more waiting", waiting) + } question := box(true, m.sdkBoxWidth()).Render( - wrapText(describePermission(*m.agentPermission), m.sdkBoxWidth()-4) + "\n" + - mutedStyle.Render("y allow once · n reject")) + wrapText(describePermission(m.agentPermissions[0]), m.sdkBoxWidth()-4) + "\n" + + mutedStyle.Render(hint)) return body + question + "\n" } status := " OpenCode is working..." From 2b35cbc726928739c3902fdbf05add4c8f047a56 Mon Sep 17 00:00:00 2001 From: Anthony Torns II Date: Mon, 19 Oct 2026 16:36:02 -0700 Subject: [PATCH 07/16] fix(setup): poll for the setup agent config instead of streaming The SDK client now polls through the relay. gonfalon holds back the relay's streamed response, so the flag stream never started, the SDK start timed out after 5 seconds, and setup used the built-in instructions without saying so. Setup reads the config once per run, so one polling request is all it needs. Polling also does not depend on a long-lived connection through the relay and the load balancer. Co-Authored-By: Claude Opus 5.5 --- internal/setup/agent/aiconfig.go | 5 +++++ internal/setup/agent/relay_sdk_test.go | 15 +++++++-------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/internal/setup/agent/aiconfig.go b/internal/setup/agent/aiconfig.go index cd9cbd024..372162640 100644 --- a/internal/setup/agent/aiconfig.go +++ b/internal/setup/agent/aiconfig.go @@ -146,6 +146,10 @@ func setEnvironmentID(id string) { // 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{ @@ -154,6 +158,7 @@ func initQuietClient(ctx context.Context, opts aiserver.InitOptions) error { Streaming: opts.StreamURI, Events: opts.EventsURI, }, + DataSource: ldcomponents.PollingDataSource(), Logging: ldcomponents.NoLogging(), DiagnosticOptOut: true, }, opts.WaitFor) diff --git a/internal/setup/agent/relay_sdk_test.go b/internal/setup/agent/relay_sdk_test.go index fda794d2b..66e9f3c91 100644 --- a/internal/setup/agent/relay_sdk_test.go +++ b/internal/setup/agent/relay_sdk_test.go @@ -38,16 +38,13 @@ func TestAIConfigSource_RealSDKThroughTheProxy(t *testing.T) { w.WriteHeader(http.StatusUnauthorized) return } - if strings.HasPrefix(r.URL.Path, RelayPath+"/stream/") { - w.Header().Set("Content-Type", "text/event-stream") - fmt.Fprintf(w, "event: put\ndata: {\"path\":\"/\",\"data\":{\"flags\":{\"ldcli-setup-agent\":%s},\"segments\":{}}}\n\n", - strings.ReplaceAll(setupAgentFlag, "\n", "")) - w.(http.Flusher).Flush() - <-r.Context().Done() + 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.Header().Set(RelayEnvironmentIDHeader, "catfood-client-id") w.WriteHeader(http.StatusAccepted) })) defer relay.Close() @@ -68,7 +65,9 @@ func TestAIConfigSource_RealSDKThroughTheProxy(t *testing.T) { for _, p := range paths { assert.Contains(t, p, RelayPath+"/", "every request goes to a relay route") } - assert.Contains(t, strings.Join(paths, "\n"), "GET "+RelayPath+"/stream/all") + 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) } From 9c8b8b81268afca8dab99e645d62c0f60f2b5b5b Mon Sep 17 00:00:00 2001 From: Anthony Torns II Date: Mon, 19 Oct 2026 16:47:31 -0700 Subject: [PATCH 08/16] fix(setup): show only the SDK's credential and say "the agent" The wait-for-app screen printed the SDK key, the client-side ID, and the mobile key, although each SDK reads only one of them. It now shows only the credential that the chosen SDK uses. The prompt also names only that environment variable, so the agent does not have to choose. The wizard now says "the agent" instead of "OpenCode" in its progress text and permission questions. Messages that tell the user to install or upgrade OpenCode keep the name, because the user must act on them. Co-Authored-By: Claude Opus 5.5 --- cmd/setup/agent.go | 40 +++++++++++++++--------------- cmd/setup/agent_test.go | 18 ++++++++------ cmd/setup/view.go | 14 +++++++---- internal/setup/agent/agent_test.go | 9 ++++++- internal/setup/agent/prompt.go | 22 ++++++++++++++-- 5 files changed, 68 insertions(+), 35 deletions(-) diff --git a/cmd/setup/agent.go b/cmd/setup/agent.go index 3faeec144..8d8351cc7 100644 --- a/cmd/setup/agent.go +++ b/cmd/setup/agent.go @@ -60,20 +60,22 @@ func agentTask(dir string, detection *setup.DetectResult, flagKey string, sdkIns return t } -// credentialExports returns the shell commands that set the credentials the -// agent's code reads. The agent never sees the values, so the user must set -// them before the application starts. -func credentialExports(sdkKey, clientSideID, mobileKey string) []string { - var lines []string - add := func(name, value string) { - if value != "" { - lines = append(lines, fmt.Sprintf("export %s=%s", name, value)) - } +// credentialExport returns the shell command that sets the credential that +// the agent's code reads for the SDK, or an empty string if that credential is +// not known. The agent never sees the value, so the user must set it before +// the application starts. Only the SDK's own credential is shown, so the +// screen does not print keys that the application does not use. +func credentialExport(sdkID, sdkKey, clientSideID, mobileKey string) string { + name := agent.CredentialEnvVar(sdkID) + value := map[string]string{ + agent.SDKKeyEnvVar: sdkKey, + agent.ClientSideIDEnvVar: clientSideID, + agent.MobileKeyEnvVar: mobileKey, + }[name] + if value == "" { + return "" } - add(agent.SDKKeyEnvVar, sdkKey) - add(agent.ClientSideIDEnvVar, clientSideID) - add(agent.MobileKeyEnvVar, mobileKey) - return lines + return fmt.Sprintf("export %s=%s", name, value) } // describeTool returns one line for a tool call. @@ -94,9 +96,9 @@ func describeTool(tc agent.ToolCall) string { // describePermission returns the question for a permission request. func describePermission(req agent.PermissionRequest) string { if len(req.Patterns) == 0 { - return fmt.Sprintf("OpenCode asks to use %s.", req.Permission) + return fmt.Sprintf("The agent asks to use %s.", req.Permission) } - return fmt.Sprintf("OpenCode asks to use %s: %s", req.Permission, strings.Join(req.Patterns, ", ")) + return fmt.Sprintf("The agent asks to use %s: %s", req.Permission, strings.Join(req.Patterns, ", ")) } func newAgentCmd(svc setup.Service, runner agent.Runner) *cobra.Command { @@ -176,12 +178,10 @@ func runAgent(svc setup.Service, runner agent.Runner) func(*cobra.Command, []str fmt.Fprintf(out, " %s\n", f) } } - if exports := credentialExports(getFlag(cmd, sdkKeyFlag), getFlag(cmd, clientIDFlag), getFlag(cmd, mobileFlag)); len(exports) > 0 { + if export := credentialExport(sdkID, getFlag(cmd, sdkKeyFlag), getFlag(cmd, clientIDFlag), getFlag(cmd, mobileFlag)); export != "" { fmt.Fprintln(out) fmt.Fprintln(out, "Set the credential before you start your application:") - for _, e := range exports { - fmt.Fprintf(out, " %s\n", e) - } + fmt.Fprintf(out, " %s\n", export) } return nil } @@ -221,7 +221,7 @@ func streamAgent(ctx context.Context, run agent.Run, out io.Writer, in *bufio.Re answer, _ := in.ReadString('\n') allow := strings.EqualFold(strings.TrimSpace(answer), "y") if err := run.Reply(ctx, ev.Permission, allow); err != nil { - return nil, fmt.Errorf("could not answer OpenCode: %w", err) + return nil, fmt.Errorf("could not answer the agent: %w", err) } case agent.EventDone: return sortedKeys(changed), nil diff --git a/cmd/setup/agent_test.go b/cmd/setup/agent_test.go index 1699d9567..ad530752f 100644 --- a/cmd/setup/agent_test.go +++ b/cmd/setup/agent_test.go @@ -128,7 +128,7 @@ func TestWizard_AgentAuto_StartsAgentWithTheTask(t *testing.T) { assert.Equal(t, "index.js", runner.tasks[0].EntryPoint) assert.Equal(t, "my-new-flag", runner.tasks[0].FlagKey) assert.NotEmpty(t, runner.tasks[0].Dir) - assert.Contains(t, m.View(), "Starting OpenCode") + assert.Contains(t, m.View(), "Starting the agent") } func TestWizard_AgentUnavailable_AutoFallsBackToInit(t *testing.T) { @@ -169,7 +169,7 @@ func TestWizard_AgentPermission_YAllows(t *testing.T) { req := agent.PermissionRequest{ID: "per1", Permission: "bash", Patterns: []string{"npm test"}} m, _ = update(t, m, agentEventMsg{run: run, ev: agent.Event{Kind: agent.EventPermission, Permission: req}}) - assert.Contains(t, m.View(), "OpenCode asks to use bash: npm test") + assert.Contains(t, m.View(), "The agent asks to use bash: npm test") m, cmd := update(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")}) require.NotNil(t, cmd) @@ -202,6 +202,7 @@ func TestWizard_AgentDone_GoesToWaitForAppWithExports(t *testing.T) { view := m.View() assert.Contains(t, view, "index.js") assert.Contains(t, view, "export LAUNCHDARKLY_SDK_KEY=sdk-123") + assert.NotContains(t, view, "LAUNCHDARKLY_MOBILE_KEY", "only the SDK's own credential is shown") m, cmd := update(t, m, tea.KeyMsg{Type: tea.KeyEnter}) assert.Equal(t, stepVerify, m.step) @@ -282,7 +283,7 @@ func TestWizard_Plan_AgentMode_SaysAgent(t *testing.T) { view := m.View() assert.Contains(t, view, "Use the setup agent to add initialization code") - assert.Contains(t, view, "uses the template if OpenCode cannot run") + assert.Contains(t, view, "uses the template if the agent cannot run") } func TestStreamAgent_AnswersPermissionAndReturnsFiles(t *testing.T) { @@ -300,7 +301,7 @@ func TestStreamAgent_AnswersPermissionAndReturnsFiles(t *testing.T) { require.NoError(t, err) assert.Equal(t, []string{"a.js", "b.js"}, files) assert.Equal(t, []fakeReply{{id: "per1", allow: true}}, run.replies) - assert.Contains(t, out.String(), "OpenCode asks to use bash: npm test") + assert.Contains(t, out.String(), "The agent asks to use bash: npm test") } func TestStreamAgent_EmptyAnswerRejects(t *testing.T) { @@ -324,8 +325,11 @@ func TestStreamAgent_Error(t *testing.T) { assert.Contains(t, err.Error(), "no API key") } -func TestCredentialExports_SkipsEmptyValues(t *testing.T) { - assert.Equal(t, []string{"export LAUNCHDARKLY_CLIENT_SIDE_ID=abc"}, credentialExports("", "abc", "")) +func TestCredentialExport_ShowsOnlyTheSDKsCredential(t *testing.T) { + assert.Equal(t, "export LAUNCHDARKLY_SDK_KEY=sdk-1", credentialExport("python-server-sdk", "sdk-1", "cid", "mob-1")) + assert.Equal(t, "export LAUNCHDARKLY_CLIENT_SIDE_ID=cid", credentialExport("react-client-sdk", "sdk-1", "cid", "mob-1")) + assert.Equal(t, "export LAUNCHDARKLY_MOBILE_KEY=mob-1", credentialExport("android", "sdk-1", "cid", "mob-1")) + assert.Equal(t, "", credentialExport("node-server", "", "cid", "mob-1"), "no value, no line") } // OpenCode can ask for several tool calls at once. Each one must get its own @@ -349,5 +353,5 @@ func TestWizard_AgentPermissions_AreQueued(t *testing.T) { assert.Equal(t, []fakeReply{{id: "per1", allow: true}, {id: "per2", allow: false}, {id: "per3", allow: true}}, run.replies) assert.Empty(t, m.agentPermissions) - assert.Contains(t, m.View(), "OpenCode is working") + assert.Contains(t, m.View(), "The agent is working") } diff --git a/cmd/setup/view.go b/cmd/setup/view.go index 538d45157..e78272e3e 100644 --- a/cmd/setup/view.go +++ b/cmd/setup/view.go @@ -383,7 +383,7 @@ func (m wizardModel) planView() string { if m.agentMode != agentOff && m.agentRunner != nil { note := "(asks before it runs a command)" if m.agentMode == agentAuto { - note = "(asks before it runs a command · uses the template if OpenCode cannot run)" + note = "(asks before it runs a command · uses the template if the agent cannot run)" } add("Use the setup agent to add initialization code " + mutedStyle.Render(note)) add("Verify the SDK connects to LaunchDarkly") @@ -440,9 +440,9 @@ func (m wizardModel) agentView() string { mutedStyle.Render(hint)) return body + question + "\n" } - status := " OpenCode is working..." + status := " The agent is working..." if m.agentRun == nil { - status = " Starting OpenCode..." + status = " Starting the agent..." } return body + m.spinner.View() + status + "\n\n" + mutedStyle.Render("q quit") } @@ -461,9 +461,13 @@ func (m wizardModel) agentWaitView() string { } body += "\n" } - if exports := credentialExports(m.sdkKey, m.clientSideID, m.mobileKey); len(exports) > 0 { + sdkID := "" + if m.detectResult != nil { + sdkID = m.detectResult.SDKID + } + if export := credentialExport(sdkID, m.sdkKey, m.clientSideID, m.mobileKey); export != "" { body += m.wrap("Set the credential that your SDK uses before you start the application:") + "\n\n" + - code(strings.Join(exports, "\n")) + "\n\n" + code(export) + "\n\n" } if m.agentUsage != nil && m.agentUsage.ModelID != "" { body += mutedStyle.Render(m.wrap(fmt.Sprintf("Model: %s/%s", m.agentUsage.ProviderID, m.agentUsage.ModelID))) + "\n\n" diff --git a/internal/setup/agent/agent_test.go b/internal/setup/agent/agent_test.go index cf027fec4..8af8fe491 100644 --- a/internal/setup/agent/agent_test.go +++ b/internal/setup/agent/agent_test.go @@ -30,10 +30,17 @@ func TestBuildPrompt_NamesTheTaskAndNoCredential(t *testing.T) { assert.Contains(t, prompt, "- Entry point: index.js") assert.Contains(t, prompt, "- Framework: unknown") assert.Contains(t, prompt, "already installed") - assert.Contains(t, prompt, SDKKeyEnvVar) + assert.Contains(t, prompt, "environment variable "+SDKKeyEnvVar) + assert.NotContains(t, prompt, MobileKeyEnvVar, "the prompt names only the SDK's own credential") assert.NotContains(t, prompt, "did not find an entry point") } +func TestCredentialEnvVar(t *testing.T) { + assert.Equal(t, SDKKeyEnvVar, CredentialEnvVar("python-server-sdk")) + assert.Equal(t, ClientSideIDEnvVar, CredentialEnvVar("js-client-sdk")) + assert.Equal(t, MobileKeyEnvVar, CredentialEnvVar("swift-client-sdk")) +} + func TestBuildPrompt_NoEntryPoint_AsksTheAgentToFindOne(t *testing.T) { prompt := BuildPrompt(Task{SDKID: "go-server-sdk", FlagKey: "f"}) diff --git a/internal/setup/agent/prompt.go b/internal/setup/agent/prompt.go index ed5b90cf8..6dab20696 100644 --- a/internal/setup/agent/prompt.go +++ b/internal/setup/agent/prompt.go @@ -14,6 +14,25 @@ const ( MobileKeyEnvVar = "LAUNCHDARKLY_MOBILE_KEY" ) +// clientSideSDKs read the client-side ID, and mobileSDKs read the mobile key. +// Every other SDK reads the server-side SDK key. +var ( + clientSideSDKs = map[string]bool{"react-client-sdk": true, "js-client-sdk": true} + mobileSDKs = map[string]bool{"react-native": true, "android": true, "swift-client-sdk": true} +) + +// CredentialEnvVar returns the environment variable that the agent's code +// must read for the SDK. +func CredentialEnvVar(sdkID string) string { + switch { + case clientSideSDKs[sdkID]: + return ClientSideIDEnvVar + case mobileSDKs[sdkID]: + return MobileKeyEnvVar + } + return SDKKeyEnvVar +} + // DefaultInstructions is the system prompt that the built-in configuration // uses. const DefaultInstructions = `You are the LaunchDarkly setup agent. You add LaunchDarkly SDK initialization code to the project in the current directory. @@ -45,8 +64,7 @@ func BuildPrompt(t Task) string { b.WriteString("The SDK package is not installed. Install it with the project's package manager.\n") } fmt.Fprintf(&b, "Evaluate the boolean flag %q once after the SDK initializes, and print or log its value.\n", t.FlagKey) - fmt.Fprintf(&b, "Read the credential from an environment variable: %s for a server-side SDK, %s for a client-side JavaScript SDK, or %s for a mobile SDK.\n", - SDKKeyEnvVar, ClientSideIDEnvVar, MobileKeyEnvVar) + fmt.Fprintf(&b, "Read the credential from the environment variable %s.\n", CredentialEnvVar(t.SDKID)) if t.EntryPoint == "" { b.WriteString("ldcli did not find an entry point. Find where the application starts before you edit.\n") } From 0aa348c5b7d95a23b8e7f2168b3336c6b4833e3f Mon Sep 17 00:00:00 2001 From: Anthony Torns II Date: Mon, 19 Oct 2026 17:20:39 -0700 Subject: [PATCH 09/16] feat(setup): show the agent's questions in the wizard The agent can ask the user to choose with OpenCode's question tool, for example where the SDK key lives. The wizard did not show these questions, so the run waited for an answer that never came. The wizard now shows each question with numbered options. A number picks an option, and s skips the request, so the agent goes on without answers. `ldcli setup agent` asks on the terminal the same way. Co-Authored-By: Claude Opus 5.5 --- cmd/setup/agent.go | 43 +++++++++++++++ cmd/setup/agent_test.go | 87 ++++++++++++++++++++++++++++++ cmd/setup/commands.go | 6 +++ cmd/setup/model.go | 9 +++- cmd/setup/update.go | 34 ++++++++++++ cmd/setup/view.go | 18 +++++++ internal/setup/agent/agent.go | 32 +++++++++++ internal/setup/agent/agent_test.go | 35 ++++++++++++ internal/setup/agent/client.go | 10 ++++ internal/setup/agent/events.go | 30 +++++++++++ internal/setup/agent/opencode.go | 4 ++ 11 files changed, 306 insertions(+), 2 deletions(-) diff --git a/cmd/setup/agent.go b/cmd/setup/agent.go index 8d8351cc7..733253d7a 100644 --- a/cmd/setup/agent.go +++ b/cmd/setup/agent.go @@ -9,6 +9,7 @@ import ( "os/signal" "path/filepath" "sort" + "strconv" "strings" "github.com/spf13/cobra" @@ -223,6 +224,14 @@ func streamAgent(ctx context.Context, run agent.Run, out io.Writer, in *bufio.Re if err := run.Reply(ctx, ev.Permission, allow); err != nil { return nil, fmt.Errorf("could not answer the agent: %w", err) } + case agent.EventQuestion: + answers, err := askQuestions(out, in, ev.Question) + if err != nil { + return nil, err + } + if err := run.Answer(ctx, ev.Question, answers); err != nil { + return nil, fmt.Errorf("could not answer the agent: %w", err) + } case agent.EventDone: return sortedKeys(changed), nil case agent.EventError: @@ -231,6 +240,40 @@ func streamAgent(ctx context.Context, run agent.Run, out io.Writer, in *bufio.Re } } +// askQuestions prints each question with numbered options and reads the +// user's choice. An empty answer skips the request, and the agent goes on +// without answers. +func askQuestions(out io.Writer, in *bufio.Reader, req agent.QuestionRequest) ([][]string, error) { + var answers [][]string + for _, q := range req.Questions { + fmt.Fprintf(out, "\nThe agent asks: %s\n", q.Text) + for i, o := range q.Options { + fmt.Fprintf(out, " %d. %s", i+1, o.Label) + if o.Description != "" { + fmt.Fprintf(out, " — %s", o.Description) + } + fmt.Fprintln(out) + } + for { + fmt.Fprintf(out, "Choose 1-%d, or press Enter to skip: ", len(q.Options)) + line, err := in.ReadString('\n') + line = strings.TrimSpace(line) + if line == "" { + return nil, nil + } + choice, convErr := strconv.Atoi(line) + if convErr == nil && choice >= 1 && choice <= len(q.Options) { + answers = append(answers, []string{q.Options[choice-1].Label}) + break + } + if err != nil { + return nil, nil + } + } + } + return answers, nil +} + func sortedKeys(m map[string]bool) []string { keys := make([]string, 0, len(m)) for k := range m { diff --git a/cmd/setup/agent_test.go b/cmd/setup/agent_test.go index ad530752f..900ac4af7 100644 --- a/cmd/setup/agent_test.go +++ b/cmd/setup/agent_test.go @@ -21,11 +21,17 @@ type fakeReply struct { allow bool } +type fakeAnswer struct { + id string + answers [][]string +} + type fakeRun struct { events chan agent.Event mu sync.Mutex replies []fakeReply + answers []fakeAnswer closed int } @@ -46,6 +52,13 @@ func (r *fakeRun) Reply(_ context.Context, req agent.PermissionRequest, allow bo return nil } +func (r *fakeRun) Answer(_ context.Context, req agent.QuestionRequest, answers [][]string) error { + r.mu.Lock() + defer r.mu.Unlock() + r.answers = append(r.answers, fakeAnswer{id: req.ID, answers: answers}) + return nil +} + func (r *fakeRun) Close() error { r.mu.Lock() defer r.mu.Unlock() @@ -355,3 +368,77 @@ func TestWizard_AgentPermissions_AreQueued(t *testing.T) { assert.Empty(t, m.agentPermissions) assert.Contains(t, m.View(), "The agent is working") } + +func secretQuestion() agent.QuestionRequest { + return agent.QuestionRequest{ID: "que1", Questions: []agent.Question{{ + Header: "Secret storage", + Text: "Where should LAUNCHDARKLY_SDK_KEY live?", + Options: []agent.QuestionOption{ + {Label: ".env (Recommended)", Description: "Added to .env."}, + {Label: "Shell profile", Description: "You export it yourself."}, + }, + }}} +} + +func TestWizard_AgentQuestion_NumberAnswers(t *testing.T) { + run := newFakeRun() + m := startedAgent(t, agentAuto, run) + m, _ = update(t, m, agentEventMsg{run: run, ev: agent.Event{Kind: agent.EventQuestion, Question: secretQuestion()}}) + view := m.View() + assert.Contains(t, view, "Where should LAUNCHDARKLY_SDK_KEY live?") + assert.Contains(t, view, "1. .env (Recommended)") + assert.Contains(t, view, "1-2 choose · s skip") + + _, none := update(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("3")}) + assert.Nil(t, none, "a number without an option does nothing") + + m, cmd := update(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("2")}) + require.NotNil(t, cmd) + cmd() + + assert.Equal(t, []fakeAnswer{{id: "que1", answers: [][]string{{"Shell profile"}}}}, run.answers) + assert.Empty(t, m.agentQuestions) +} + +func TestWizard_AgentQuestion_SSkips(t *testing.T) { + run := newFakeRun() + m := startedAgent(t, agentAuto, run) + m, _ = update(t, m, agentEventMsg{run: run, ev: agent.Event{Kind: agent.EventQuestion, Question: secretQuestion()}}) + + _, cmd := update(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("s")}) + require.NotNil(t, cmd) + cmd() + + assert.Equal(t, []fakeAnswer{{id: "que1", answers: nil}}, run.answers) +} + +func TestWizard_AgentQuestion_SeveralQuestionsAnsweredInOrder(t *testing.T) { + run := newFakeRun() + m := startedAgent(t, agentAuto, run) + req := secretQuestion() + req.Questions = append(req.Questions, agent.Question{Header: "Flag", Text: "Log the value?", Options: []agent.QuestionOption{{Label: "Yes"}, {Label: "No"}}}) + m, _ = update(t, m, agentEventMsg{run: run, ev: agent.Event{Kind: agent.EventQuestion, Question: req}}) + + m, cmd := update(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("1")}) + assert.Nil(t, cmd, "the wizard waits for the second answer") + assert.Contains(t, m.View(), "question 2 of 2") + _, cmd = update(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("2")}) + require.NotNil(t, cmd) + cmd() + + assert.Equal(t, []fakeAnswer{{id: "que1", answers: [][]string{{".env (Recommended)"}, {"No"}}}}, run.answers) +} + +func TestStreamAgent_AnswersQuestion(t *testing.T) { + run := newFakeRun( + agent.Event{Kind: agent.EventQuestion, Question: secretQuestion()}, + agent.Event{Kind: agent.EventDone}, + ) + var out bytes.Buffer + + _, err := streamAgent(context.Background(), run, &out, bufio.NewReader(strings.NewReader("9\n1\n"))) + + require.NoError(t, err) + assert.Equal(t, []fakeAnswer{{id: "que1", answers: [][]string{{".env (Recommended)"}}}}, run.answers, "an invalid number asks again") + assert.Contains(t, out.String(), "The agent asks: Where should LAUNCHDARKLY_SDK_KEY live?") +} diff --git a/cmd/setup/commands.go b/cmd/setup/commands.go index d069b8afb..54e298266 100644 --- a/cmd/setup/commands.go +++ b/cmd/setup/commands.go @@ -240,6 +240,12 @@ func waitForAgentEvent(run agent.Run) tea.Cmd { } } +func answerAgent(run agent.Run, req agent.QuestionRequest, answers [][]string) tea.Cmd { + return func() tea.Msg { + return agentRepliedMsg{err: run.Answer(context.Background(), req, answers)} + } +} + func replyAgent(run agent.Run, req agent.PermissionRequest, allow bool) tea.Cmd { return func() tea.Msg { return agentRepliedMsg{err: run.Reply(context.Background(), req, allow)} diff --git a/cmd/setup/model.go b/cmd/setup/model.go index 6dbd4d41f..a22b0c44f 100644 --- a/cmd/setup/model.go +++ b/cmd/setup/model.go @@ -118,8 +118,13 @@ type wizardModel struct { // first. OpenCode can ask for several tool calls at once, and each one // waits until it gets its own answer. agentPermissions []agent.PermissionRequest - agentUsage *agent.Usage - agentDone bool + // agentQuestions are the agent's question requests that wait for answers, + // oldest first. agentAnswers holds the answers to the first request so far, + // one for each of its questions that the user answered. + agentQuestions []agent.QuestionRequest + agentAnswers [][]string + agentUsage *agent.Usage + agentDone bool // agentNote tells the user why setup used the templates after the agent // could not run. agentNote string diff --git a/cmd/setup/update.go b/cmd/setup/update.go index 223fccc7b..0bbc6719e 100644 --- a/cmd/setup/update.go +++ b/cmd/setup/update.go @@ -56,6 +56,10 @@ func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.agentPermissions = m.agentPermissions[1:] return m, replyAgent(m.agentRun, req, msg.String() == "y") } + case "1", "2", "3", "4", "5", "6", "7", "8", "9", "s": + if m.step == stepAgent && len(m.agentPermissions) == 0 && len(m.agentQuestions) > 0 && m.agentRun != nil { + return m.answerAgentQuestion(msg.String()) + } case "left", "h": if m.isFiltering() { break // let the list receive the key as filter input @@ -274,6 +278,8 @@ func (m wizardModel) handleAgentEvent(msg agentEventMsg) (tea.Model, tea.Cmd) { case agent.EventPermission: req := ev.Permission m.agentPermissions = append(m.agentPermissions, req) + case agent.EventQuestion: + m.agentQuestions = append(m.agentQuestions, ev.Question) case agent.EventDone: m.agentDone = true m.closeAgent() @@ -297,6 +303,8 @@ func (m wizardModel) agentFailed(err error) (tea.Model, tea.Cmd) { m.closeAgent() m.agentRun = nil m.agentPermissions = nil + m.agentQuestions = nil + m.agentAnswers = nil if m.agentMode == agentOn || len(m.agentFiles) > 0 { m.err = errors.New("The setup agent failed: " + err.Error()) return m, nil @@ -307,6 +315,32 @@ func (m wizardModel) agentFailed(err error) (tea.Model, tea.Cmd) { return m, m.runInit() } +// answerAgentQuestion records the user's choice for the current question. A +// number picks an option, and s skips the whole request, so the agent goes on +// without answers. When every question of the request has an answer, the +// wizard sends them. +func (m wizardModel) answerAgentQuestion(key string) (tea.Model, tea.Cmd) { + req := m.agentQuestions[0] + if key == "s" { + m.agentQuestions = m.agentQuestions[1:] + m.agentAnswers = nil + return m, answerAgent(m.agentRun, req, nil) + } + question := req.Questions[len(m.agentAnswers)] + choice := int(key[0] - '1') + if choice >= len(question.Options) { + return m, nil + } + m.agentAnswers = append(m.agentAnswers, []string{question.Options[choice].Label}) + if len(m.agentAnswers) < len(req.Questions) { + return m, nil + } + answers := m.agentAnswers + m.agentQuestions = m.agentQuestions[1:] + m.agentAnswers = nil + return m, answerAgent(m.agentRun, req, answers) +} + // closeAgent stops OpenCode if a run is active. func (m wizardModel) closeAgent() { if m.agentRun != nil { diff --git a/cmd/setup/view.go b/cmd/setup/view.go index e78272e3e..fa479afe8 100644 --- a/cmd/setup/view.go +++ b/cmd/setup/view.go @@ -440,6 +440,24 @@ func (m wizardModel) agentView() string { mutedStyle.Render(hint)) return body + question + "\n" } + if len(m.agentQuestions) > 0 { + req := m.agentQuestions[0] + question := req.Questions[len(m.agentAnswers)] + width := m.sdkBoxWidth() - 4 + text := headerStyle.Render(question.Header) + "\n" + wrapText(question.Text, width) + "\n\n" + for i, o := range question.Options { + line := fmt.Sprintf("%d. %s", i+1, o.Label) + if o.Description != "" { + line += " — " + o.Description + } + text += wrapText(line, width) + "\n" + } + hint := fmt.Sprintf("1-%d choose · s skip", len(question.Options)) + if total := len(req.Questions); total > 1 { + hint = fmt.Sprintf("question %d of %d · ", len(m.agentAnswers)+1, total) + hint + } + return body + box(true, m.sdkBoxWidth()).Render(text+"\n"+mutedStyle.Render(hint)) + "\n" + } status := " The agent is working..." if m.agentRun == nil { status = " Starting the agent..." diff --git a/internal/setup/agent/agent.go b/internal/setup/agent/agent.go index 6a94c6a57..7116c0584 100644 --- a/internal/setup/agent/agent.go +++ b/internal/setup/agent/agent.go @@ -61,6 +61,9 @@ const ( EventTool // EventPermission asks the user to allow or reject a tool call. EventPermission + // EventQuestion asks the user to choose an option for each question in + // Question. + EventQuestion // EventFileEdited reports a file that OpenCode changed, in File. EventFileEdited // EventUsage reports the model and the tokens of one finished reply. @@ -78,6 +81,7 @@ type Event struct { Text string Tool ToolCall Permission PermissionRequest + Question QuestionRequest File string Usage Usage } @@ -106,6 +110,30 @@ type PermissionRequest struct { legacy bool } +// QuestionRequest is a set of questions from the agent that waits for the +// user's answers. The agent asks with OpenCode's question tool. +type QuestionRequest struct { + ID string + SessionID string + Questions []Question +} + +// Question is one question with its choices. +type Question struct { + // Header is a short label, at most 30 characters. + Header string + Text string + Options []QuestionOption + // Multiple reports that the user can choose more than one option. + Multiple bool +} + +// QuestionOption is one choice for a question. +type QuestionOption struct { + Label string + Description string +} + // Usage is the model and the token counts of one finished reply. type Usage struct { ProviderID string @@ -131,6 +159,10 @@ type Run interface { Events() <-chan Event // Reply answers a permission request. Reply(ctx context.Context, req PermissionRequest, allow bool) error + // Answer replies to a question request with the chosen option labels, one + // list for each question, in order. A nil answers value dismisses the + // questions, and the agent continues without answers. + Answer(ctx context.Context, req QuestionRequest, answers [][]string) error // Close stops OpenCode. It is safe to call more than once. Close() error } diff --git a/internal/setup/agent/agent_test.go b/internal/setup/agent/agent_test.go index 8af8fe491..100efccd3 100644 --- a/internal/setup/agent/agent_test.go +++ b/internal/setup/agent/agent_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" "net/http/httptest" "strings" @@ -175,6 +176,18 @@ func TestDecoder_Permission(t *testing.T) { assert.Equal(t, PermissionRequest{ID: "per1", SessionID: "ses", Permission: "bash", Patterns: []string{"npm test"}}, out[0].Permission) } +func TestDecoder_Question(t *testing.T) { + d := newDecoder("ses") + + out := decodeAll(d, `{"type":"question.asked","properties":{"id":"que1","sessionID":"ses","questions":[{"question":"Where?","header":"Secret","options":[{"label":".env","description":"gitignored"}]}]}}`) + + require.Len(t, out, 1) + assert.Equal(t, EventQuestion, out[0].Kind) + assert.Equal(t, QuestionRequest{ID: "que1", SessionID: "ses", Questions: []Question{{ + Header: "Secret", Text: "Where?", Options: []QuestionOption{{Label: ".env", Description: "gitignored"}}, + }}}, out[0].Question) +} + func TestDecoder_LegacyPermission(t *testing.T) { d := newDecoder("ses") @@ -253,6 +266,13 @@ func newFakeOpenCode(t *testing.T) *fakeOpenCode { f.mu.Unlock() w.WriteHeader(http.StatusNoContent) }) + mux.HandleFunc("POST /question/{id}/{action}", func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + f.mu.Lock() + f.replies = append(f.replies, r.PathValue("id")+" "+r.PathValue("action")+" "+strings.TrimSpace(string(body))) + f.mu.Unlock() + _, _ = w.Write([]byte("true")) + }) mux.HandleFunc("POST /permission/{id}/reply", func(w http.ResponseWriter, r *http.Request) { var body map[string]string _ = json.NewDecoder(r.Body).Decode(&body) @@ -325,6 +345,21 @@ func TestRun_RepliesToPermission(t *testing.T) { assert.Equal(t, []string{"per1=once", "per1=reject"}, f.replies) } +func TestRun_AnswersQuestion(t *testing.T) { + f := newFakeOpenCode(t) + run, err := startRun(context.Background(), f.client(), Config{}, "task") + require.NoError(t, err) + defer run.Close() + req := QuestionRequest{ID: "que1"} + + require.NoError(t, run.Answer(context.Background(), req, [][]string{{".env"}})) + require.NoError(t, run.Answer(context.Background(), req, nil)) + + f.mu.Lock() + defer f.mu.Unlock() + assert.Equal(t, []string{`que1 reply {"answers":[[".env"]]}`, "que1 reject {}"}, f.replies) +} + func TestRun_StreamEndsEarly_ReportsError(t *testing.T) { f := newFakeOpenCode(t) run, err := startRun(context.Background(), f.client(), Config{}, "task") diff --git a/internal/setup/agent/client.go b/internal/setup/agent/client.go index 42da5619c..82f68aa9e 100644 --- a/internal/setup/agent/client.go +++ b/internal/setup/agent/client.go @@ -116,6 +116,16 @@ func (c *client) replyPermission(ctx context.Context, req PermissionRequest, all return c.do(ctx, http.MethodPost, "/permission/"+url.PathEscape(req.ID)+"/reply", map[string]string{"reply": reply}, nil) } +// answerQuestion replies to a question request, or dismisses it when answers +// is nil. +func (c *client) answerQuestion(ctx context.Context, req QuestionRequest, answers [][]string) error { + path := "/question/" + url.PathEscape(req.ID) + if answers == nil { + return c.do(ctx, http.MethodPost, path+"/reject", map[string]any{}, nil) + } + return c.do(ctx, http.MethodPost, path+"/reply", map[string]any{"answers": answers}, nil) +} + // subscribe opens the event stream. The caller must close the body. func (c *client) subscribe(ctx context.Context) (io.ReadCloser, error) { req, err := c.newRequest(ctx, http.MethodGet, "/event", nil) diff --git a/internal/setup/agent/events.go b/internal/setup/agent/events.go index 8dccd847a..fe3ad486b 100644 --- a/internal/setup/agent/events.go +++ b/internal/setup/agent/events.go @@ -60,6 +60,8 @@ func (d *decoder) decode(data []byte) []Event { out = d.permissionAsked(ev.Properties) case "permission.updated": out = d.legacyPermission(ev.Properties) + case "question.asked": + out = d.questionAsked(ev.Properties) case "file.edited": var p struct { File string `json:"file"` @@ -200,6 +202,34 @@ func (d *decoder) permissionAsked(props json.RawMessage) []Event { }}} } +func (d *decoder) questionAsked(props json.RawMessage) []Event { + var p struct { + ID string `json:"id"` + SessionID string `json:"sessionID"` + Questions []struct { + Question string `json:"question"` + Header string `json:"header"` + Multiple bool `json:"multiple"` + Options []struct { + Label string `json:"label"` + Description string `json:"description"` + } `json:"options"` + } `json:"questions"` + } + if json.Unmarshal(props, &p) != nil || p.SessionID != d.sessionID || len(p.Questions) == 0 { + return nil + } + req := QuestionRequest{ID: p.ID, SessionID: p.SessionID} + for _, q := range p.Questions { + question := Question{Header: q.Header, Text: q.Question, Multiple: q.Multiple} + for _, o := range q.Options { + question.Options = append(question.Options, QuestionOption{Label: o.Label, Description: o.Description}) + } + req.Questions = append(req.Questions, question) + } + return []Event{{Kind: EventQuestion, Question: req}} +} + // legacyPermission reads the permission event of OpenCode versions before // permission.asked. Its pattern is a string or a list of strings. func (d *decoder) legacyPermission(props json.RawMessage) []Event { diff --git a/internal/setup/agent/opencode.go b/internal/setup/agent/opencode.go index ed6616ded..1c89bf29f 100644 --- a/internal/setup/agent/opencode.go +++ b/internal/setup/agent/opencode.go @@ -203,6 +203,10 @@ func (r *openCodeRun) Reply(ctx context.Context, req PermissionRequest, allow bo return r.client.replyPermission(ctx, req, allow) } +func (r *openCodeRun) Answer(ctx context.Context, req QuestionRequest, answers [][]string) error { + return r.client.answerQuestion(ctx, req, answers) +} + func (r *openCodeRun) Close() error { r.closeOnce.Do(func() { close(r.closed) From e84a0e14d2371e6db73f960df293949fcb549f98 Mon Sep 17 00:00:00 2001 From: Anthony Torns II Date: Mon, 19 Oct 2026 17:27:26 -0700 Subject: [PATCH 10/16] feat(setup): let the AI Config describe the setup task If the instructions of the setup-agent AI Config use a {{variable}}, they describe the task themselves. The CLI fills the variables with the detected facts and the earlier setup choices, and it sends only a short start message. So AgentControl controls the whole job without a CLI release. Instructions without variables keep the CLI's own task message, so a config can add guidance without taking over the task. The variables are sdkId, language, framework, packageManager, entryPoint, sdkInstalled, flagKey, and credentialEnvVar. The CLI fills them with aiserver.ParseTemplate, the same function the AI SDK uses. Co-Authored-By: Claude Opus 5.5 --- internal/setup/agent/agent.go | 3 +++ internal/setup/agent/aiconfig.go | 17 +++++++++++++++-- internal/setup/agent/aiconfig_test.go | 21 +++++++++++++++++++++ internal/setup/agent/opencode.go | 6 +++++- internal/setup/agent/prompt.go | 27 +++++++++++++++++++++++++++ 5 files changed, 71 insertions(+), 3 deletions(-) diff --git a/internal/setup/agent/agent.go b/internal/setup/agent/agent.go index 7116c0584..6fcaf103a 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/aiconfig.go b/internal/setup/agent/aiconfig.go index 372162640..16a8fc28c 100644 --- a/internal/setup/agent/aiconfig.go +++ b/internal/setup/agent/aiconfig.go @@ -8,6 +8,7 @@ import ( "log/slog" "net/http" "os" + "regexp" "strings" "sync" "time" @@ -198,7 +199,7 @@ func (s *AIConfigSource) Resolve(ctx context.Context, task Task) (Config, error) if !res.Enabled || res.Config == nil { return s.fallback(ctx, task) } - return configFromAI(res.Config), nil + return configFromAI(res.Config, task), nil } func (s *AIConfigSource) fallback(ctx context.Context, task Task) (Config, error) { @@ -255,10 +256,19 @@ var openCodeProviders = map[string]string{ "gemini": "google", } +// placeholderPattern matches a {{name}} variable, with the same rule as +// aiserver.ParseTemplate. +var placeholderPattern = regexp.MustCompile(`\{\{[\w.]+\}\}`) + // 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. -func configFromAI(c *aiserver.AiConfig) Config { +// +// If the instructions use at least one {{variable}}, they describe the task +// themselves: the CLI fills the variables from TaskVariables and sends only +// StartMessage. Instructions without variables keep the CLI's own task +// message, so a config can add guidance without taking over the task. +func configFromAI(c *aiserver.AiConfig, task Task) Config { cfg := Config{Instructions: c.Instructions, ModelID: c.Model.Name} if cfg.Instructions == "" { var system []string @@ -271,6 +281,9 @@ func configFromAI(c *aiserver.AiConfig) Config { } if cfg.Instructions == "" { cfg.Instructions = DefaultInstructions + } else if placeholderPattern.MatchString(cfg.Instructions) { + cfg.Instructions = aiserver.ParseTemplate(cfg.Instructions, TaskVariables(task)) + cfg.Task = StartMessage } provider := strings.ToLower(strings.TrimSpace(c.Provider.Name)) if mapped, ok := openCodeProviders[provider]; ok { diff --git a/internal/setup/agent/aiconfig_test.go b/internal/setup/agent/aiconfig_test.go index 9b33c0deb..d623e244f 100644 --- a/internal/setup/agent/aiconfig_test.go +++ b/internal/setup/agent/aiconfig_test.go @@ -128,6 +128,27 @@ func TestAIConfigSource_AgentMode(t *testing.T) { } } +func TestAIConfigSource_InstructionsWithVariables_OwnTheTask(t *testing.T) { + s, _ := fakeSource(nil, enabled(agentConfig("anthropic", "claude-sonnet-5", + "Set up {{sdkId}} in {{entryPoint}}, read {{credentialEnvVar}}, evaluate {{flagKey}}. Installed: {{sdkInstalled}}. Keep {{unknown}}."))) + + cfg, err := s.Resolve(context.Background(), Task{SDKID: "python-server-sdk", EntryPoint: "main.py", FlagKey: "my-new-flag", SDKInstalled: true}) + + require.NoError(t, err) + assert.Equal(t, "Set up python-server-sdk in main.py, read LAUNCHDARKLY_SDK_KEY, evaluate my-new-flag. Installed: true. Keep {{unknown}}.", cfg.Instructions) + assert.Equal(t, StartMessage, cfg.Task, "the config describes the task, so the CLI sends only the start message") +} + +func TestAIConfigSource_InstructionsWithoutVariables_KeepTheCLITask(t *testing.T) { + s, _ := fakeSource(nil, enabled(agentConfig("anthropic", "claude-sonnet-5", "Prefer small diffs."))) + + cfg, err := s.Resolve(context.Background(), Task{SDKID: "python-server-sdk"}) + + require.NoError(t, err) + assert.Equal(t, "Prefer small diffs.", cfg.Instructions) + assert.Empty(t, cfg.Task, "the run sends the CLI's own task message") +} + 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"}} diff --git a/internal/setup/agent/opencode.go b/internal/setup/agent/opencode.go index 1c89bf29f..9c10292d7 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 6dab20696..934824631 100644 --- a/internal/setup/agent/prompt.go +++ b/internal/setup/agent/prompt.go @@ -46,6 +46,33 @@ 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.` +// StartMessage is the task message when the AI Config's instructions describe +// the task themselves. +const StartMessage = "Do the setup task that your instructions describe, in the project in the current directory." + +// TaskVariables are the values that an AI Config's instructions can use as +// {{name}} placeholders. They are the facts that the CLI detected and the +// choices that it made earlier in setup. +// +// - sdkId: the SDK, for example python-server-sdk +// - language, framework, packageManager: detected facts; empty if unknown +// - entryPoint: the detected entry point; empty if none was found +// - sdkInstalled: true if the install step added the SDK package +// - flagKey: the flag that the new code evaluates +// - credentialEnvVar: the environment variable that holds the SDK credential +func TaskVariables(t Task) map[string]any { + return map[string]any{ + "sdkId": t.SDKID, + "language": t.Language, + "framework": t.Framework, + "packageManager": t.PackageManager, + "entryPoint": t.EntryPoint, + "sdkInstalled": t.SDKInstalled, + "flagKey": t.FlagKey, + "credentialEnvVar": CredentialEnvVar(t.SDKID), + } +} + // BuildPrompt returns the task message for one run. func BuildPrompt(t Task) string { var b strings.Builder From b6870b833a606285aa3829696c4d7de866f68171 Mon Sep 17 00:00:00 2001 From: Anthony Torns II Date: Mon, 19 Oct 2026 17:30:57 -0700 Subject: [PATCH 11/16] Revert "feat(setup): let the AI Config describe the setup task" This reverts commit e84a0e14d2371e6db73f960df293949fcb549f98. --- internal/setup/agent/agent.go | 3 --- internal/setup/agent/aiconfig.go | 17 ++--------------- internal/setup/agent/aiconfig_test.go | 21 --------------------- internal/setup/agent/opencode.go | 6 +----- internal/setup/agent/prompt.go | 27 --------------------------- 5 files changed, 3 insertions(+), 71 deletions(-) diff --git a/internal/setup/agent/agent.go b/internal/setup/agent/agent.go index 6fcaf103a..7116c0584 100644 --- a/internal/setup/agent/agent.go +++ b/internal/setup/agent/agent.go @@ -32,9 +32,6 @@ 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/aiconfig.go b/internal/setup/agent/aiconfig.go index 16a8fc28c..372162640 100644 --- a/internal/setup/agent/aiconfig.go +++ b/internal/setup/agent/aiconfig.go @@ -8,7 +8,6 @@ import ( "log/slog" "net/http" "os" - "regexp" "strings" "sync" "time" @@ -199,7 +198,7 @@ func (s *AIConfigSource) Resolve(ctx context.Context, task Task) (Config, error) if !res.Enabled || res.Config == nil { return s.fallback(ctx, task) } - return configFromAI(res.Config, task), nil + return configFromAI(res.Config), nil } func (s *AIConfigSource) fallback(ctx context.Context, task Task) (Config, error) { @@ -256,19 +255,10 @@ var openCodeProviders = map[string]string{ "gemini": "google", } -// placeholderPattern matches a {{name}} variable, with the same rule as -// aiserver.ParseTemplate. -var placeholderPattern = regexp.MustCompile(`\{\{[\w.]+\}\}`) - // 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. -// -// If the instructions use at least one {{variable}}, they describe the task -// themselves: the CLI fills the variables from TaskVariables and sends only -// StartMessage. Instructions without variables keep the CLI's own task -// message, so a config can add guidance without taking over the task. -func configFromAI(c *aiserver.AiConfig, task Task) Config { +func configFromAI(c *aiserver.AiConfig) Config { cfg := Config{Instructions: c.Instructions, ModelID: c.Model.Name} if cfg.Instructions == "" { var system []string @@ -281,9 +271,6 @@ func configFromAI(c *aiserver.AiConfig, task Task) Config { } if cfg.Instructions == "" { cfg.Instructions = DefaultInstructions - } else if placeholderPattern.MatchString(cfg.Instructions) { - cfg.Instructions = aiserver.ParseTemplate(cfg.Instructions, TaskVariables(task)) - cfg.Task = StartMessage } provider := strings.ToLower(strings.TrimSpace(c.Provider.Name)) if mapped, ok := openCodeProviders[provider]; ok { diff --git a/internal/setup/agent/aiconfig_test.go b/internal/setup/agent/aiconfig_test.go index d623e244f..9b33c0deb 100644 --- a/internal/setup/agent/aiconfig_test.go +++ b/internal/setup/agent/aiconfig_test.go @@ -128,27 +128,6 @@ func TestAIConfigSource_AgentMode(t *testing.T) { } } -func TestAIConfigSource_InstructionsWithVariables_OwnTheTask(t *testing.T) { - s, _ := fakeSource(nil, enabled(agentConfig("anthropic", "claude-sonnet-5", - "Set up {{sdkId}} in {{entryPoint}}, read {{credentialEnvVar}}, evaluate {{flagKey}}. Installed: {{sdkInstalled}}. Keep {{unknown}}."))) - - cfg, err := s.Resolve(context.Background(), Task{SDKID: "python-server-sdk", EntryPoint: "main.py", FlagKey: "my-new-flag", SDKInstalled: true}) - - require.NoError(t, err) - assert.Equal(t, "Set up python-server-sdk in main.py, read LAUNCHDARKLY_SDK_KEY, evaluate my-new-flag. Installed: true. Keep {{unknown}}.", cfg.Instructions) - assert.Equal(t, StartMessage, cfg.Task, "the config describes the task, so the CLI sends only the start message") -} - -func TestAIConfigSource_InstructionsWithoutVariables_KeepTheCLITask(t *testing.T) { - s, _ := fakeSource(nil, enabled(agentConfig("anthropic", "claude-sonnet-5", "Prefer small diffs."))) - - cfg, err := s.Resolve(context.Background(), Task{SDKID: "python-server-sdk"}) - - require.NoError(t, err) - assert.Equal(t, "Prefer small diffs.", cfg.Instructions) - assert.Empty(t, cfg.Task, "the run sends the CLI's own task message") -} - 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"}} diff --git a/internal/setup/agent/opencode.go b/internal/setup/agent/opencode.go index 9c10292d7..1c89bf29f 100644 --- a/internal/setup/agent/opencode.go +++ b/internal/setup/agent/opencode.go @@ -86,11 +86,7 @@ func (o *OpenCode) Start(ctx context.Context, task Task) (Run, error) { return nil, err } - prompt := cfg.Task - if prompt == "" { - prompt = BuildPrompt(task) - } - run, err := startRun(ctx, srv.client, cfg, prompt) + run, err := startRun(ctx, srv.client, cfg, BuildPrompt(task)) if err != nil { srv.stop() return nil, err diff --git a/internal/setup/agent/prompt.go b/internal/setup/agent/prompt.go index 934824631..6dab20696 100644 --- a/internal/setup/agent/prompt.go +++ b/internal/setup/agent/prompt.go @@ -46,33 +46,6 @@ 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.` -// StartMessage is the task message when the AI Config's instructions describe -// the task themselves. -const StartMessage = "Do the setup task that your instructions describe, in the project in the current directory." - -// TaskVariables are the values that an AI Config's instructions can use as -// {{name}} placeholders. They are the facts that the CLI detected and the -// choices that it made earlier in setup. -// -// - sdkId: the SDK, for example python-server-sdk -// - language, framework, packageManager: detected facts; empty if unknown -// - entryPoint: the detected entry point; empty if none was found -// - sdkInstalled: true if the install step added the SDK package -// - flagKey: the flag that the new code evaluates -// - credentialEnvVar: the environment variable that holds the SDK credential -func TaskVariables(t Task) map[string]any { - return map[string]any{ - "sdkId": t.SDKID, - "language": t.Language, - "framework": t.Framework, - "packageManager": t.PackageManager, - "entryPoint": t.EntryPoint, - "sdkInstalled": t.SDKInstalled, - "flagKey": t.FlagKey, - "credentialEnvVar": CredentialEnvVar(t.SDKID), - } -} - // BuildPrompt returns the task message for one run. func BuildPrompt(t Task) string { var b strings.Builder From 167a10f713d47243980f84124babf345b1810447 Mon Sep 17 00:00:00 2001 From: Anthony Torns II Date: Mon, 19 Oct 2026 17:32:10 -0700 Subject: [PATCH 12/16] feat(setup): let the AI Config's instructions decide the task When the setup-agent AI Config loads, its instructions are the agent's system prompt, and the first user message gives only the facts from setup: the SDK, the language, the entry point, the flag, and the credential variable. The CLI no longer adds its own task, so a task in the instructions is not overruled by the CLI's flag-only task. If the config cannot load or has no instructions, the built-in instructions and the CLI's own task message apply. Co-Authored-By: Claude Opus 5.5 --- internal/setup/agent/agent.go | 3 +++ internal/setup/agent/agent_test.go | 11 +++++++++++ internal/setup/agent/aiconfig.go | 10 ++++++++-- internal/setup/agent/aiconfig_test.go | 9 +++++++-- internal/setup/agent/opencode.go | 6 +++++- internal/setup/agent/prompt.go | 23 +++++++++++++++++++++++ internal/setup/agent/relay_sdk_test.go | 3 ++- 7 files changed, 59 insertions(+), 6 deletions(-) diff --git a/internal/setup/agent/agent.go b/internal/setup/agent/agent.go index 7116c0584..6fcaf103a 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 100efccd3..26c9c19bb 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 index 372162640..12e2197fe 100644 --- a/internal/setup/agent/aiconfig.go +++ b/internal/setup/agent/aiconfig.go @@ -198,7 +198,7 @@ func (s *AIConfigSource) Resolve(ctx context.Context, task Task) (Config, error) if !res.Enabled || res.Config == nil { return s.fallback(ctx, task) } - return configFromAI(res.Config), nil + return configFromAI(res.Config, task), nil } func (s *AIConfigSource) fallback(ctx context.Context, task Task) (Config, error) { @@ -258,7 +258,11 @@ var openCodeProviders = map[string]string{ // 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. -func configFromAI(c *aiserver.AiConfig) Config { +// +// 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 @@ -271,6 +275,8 @@ func configFromAI(c *aiserver.AiConfig) Config { } 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 { diff --git a/internal/setup/agent/aiconfig_test.go b/internal/setup/agent/aiconfig_test.go index 9b33c0deb..d6c56a69f 100644 --- a/internal/setup/agent/aiconfig_test.go +++ b/internal/setup/agent/aiconfig_test.go @@ -112,7 +112,12 @@ func TestAIConfigSource_AgentMode(t *testing.T) { 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, Config{ProviderID: "anthropic", ModelID: "claude-sonnet-5", Instructions: "Edit the entry point."}, cfg) + 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) @@ -154,7 +159,7 @@ func TestAIConfigSource_ModelWithoutProvider_UsesOpenCodeDefault(t *testing.T) { cfg, err := s.Resolve(context.Background(), Task{}) require.NoError(t, err) - assert.Equal(t, Config{Instructions: DefaultInstructions}, cfg) + assert.Equal(t, Config{Instructions: DefaultInstructions}, cfg, "no instructions: the built-in task applies") } func TestAIConfigSource_FallsBack(t *testing.T) { diff --git a/internal/setup/agent/opencode.go b/internal/setup/agent/opencode.go index 1c89bf29f..9c10292d7 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 6dab20696..ba39f9471 100644 --- a/internal/setup/agent/prompt.go +++ b/internal/setup/agent/prompt.go @@ -46,6 +46,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_sdk_test.go b/internal/setup/agent/relay_sdk_test.go index 66e9f3c91..f8ebd8cd2 100644 --- a/internal/setup/agent/relay_sdk_test.go +++ b/internal/setup/agent/relay_sdk_test.go @@ -57,7 +57,8 @@ func TestAIConfigSource_RealSDKThroughTheProxy(t *testing.T) { cfg, err := source.Resolve(context.Background(), Task{SDKID: "node-server"}) require.NoError(t, err) - assert.Equal(t, Config{ProviderID: "anthropic", ModelID: "claude-sonnet-5", Instructions: "From AgentControl."}, cfg) + 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() From c84a63a4139b5827c3b4aac1c5524295fa565faa Mon Sep 17 00:00:00 2001 From: Anthony Torns II Date: Mon, 19 Oct 2026 17:45:13 -0700 Subject: [PATCH 13/16] feat(setup): run the agent's commands without approvals The agent now runs shell commands in the project without asking. It asks the user only with the question tool, when it needs information such as where to store a key. Approving every command made a run slow, and most commands only read the project. Some limits stay. Rules deny git commit, git push, git reset, sudo, and rm -rf on absolute paths. Files outside the project, web access, and sub-agents stay denied. Co-Authored-By: Claude Opus 5.5 --- cmd/setup/agent.go | 3 +- cmd/setup/view.go | 4 +- internal/setup/agent/agent_test.go | 18 +++++--- internal/setup/agent/opencode_config.go | 55 ++++++++++++++++++++++--- 4 files changed, 66 insertions(+), 14 deletions(-) diff --git a/cmd/setup/agent.go b/cmd/setup/agent.go index 733253d7a..f37957257 100644 --- a/cmd/setup/agent.go +++ b/cmd/setup/agent.go @@ -109,7 +109,8 @@ func newAgentCmd(svc setup.Service, runner agent.Runner) *cobra.Command { Long: `Runs only the agent step of setup. The setup agent uses OpenCode to edit the project. OpenCode must be installed. -The agent asks before it runs a shell command. It never receives your +The agent runs commands in the project without asking, and it asks you only +when it needs information, such as where to store a key. It never receives your LaunchDarkly credentials: the code that it writes reads them from environment variables.`, RunE: runAgent(svc, runner), diff --git a/cmd/setup/view.go b/cmd/setup/view.go index fa479afe8..691c58910 100644 --- a/cmd/setup/view.go +++ b/cmd/setup/view.go @@ -381,9 +381,9 @@ func (m wizardModel) planView() string { } add(fmt.Sprintf("Create a feature flag in %s / %s", m.selectedProject, m.selectedEnv)) if m.agentMode != agentOff && m.agentRunner != nil { - note := "(asks before it runs a command)" + note := "(asks you only when it needs information)" if m.agentMode == agentAuto { - note = "(asks before it runs a command · uses the template if the agent cannot run)" + note = "(asks you only when it needs information · uses the template if the agent cannot run)" } add("Use the setup agent to add initialization code " + mutedStyle.Render(note)) add("Verify the SDK connects to LaunchDarkly") diff --git a/internal/setup/agent/agent_test.go b/internal/setup/agent/agent_test.go index 26c9c19bb..76f5e46f1 100644 --- a/internal/setup/agent/agent_test.go +++ b/internal/setup/agent/agent_test.go @@ -69,18 +69,24 @@ func TestChildEnv_RemovesLDVariablesAndSetsOwn(t *testing.T) { assert.ElementsMatch(t, []string{"PATH=/bin", "HOME=/home/me", "OPENCODE_SERVER_PASSWORD=new"}, env) } -func TestOpenCodeConfig_AsksBeforeShellCommands(t *testing.T) { +func TestOpenCodeConfig_RunsCommandsWithoutApprovals(t *testing.T) { data, err := openCodeConfig() require.NoError(t, err) var cfg struct { - Permission map[string]string `json:"permission"` + Permission map[string]json.RawMessage `json:"permission"` } require.NoError(t, json.Unmarshal(data, &cfg)) - assert.Equal(t, "ask", cfg.Permission["bash"]) - assert.Equal(t, "allow", cfg.Permission["edit"]) - assert.Equal(t, "deny", cfg.Permission["external_directory"]) - assert.Equal(t, "deny", cfg.Permission["webfetch"]) + assert.JSONEq(t, `"allow"`, string(cfg.Permission["edit"])) + assert.JSONEq(t, `"allow"`, string(cfg.Permission["question"]), "the agent can still ask for information") + assert.JSONEq(t, `"deny"`, string(cfg.Permission["external_directory"])) + assert.JSONEq(t, `"deny"`, string(cfg.Permission["webfetch"])) + + bash := string(cfg.Permission["bash"]) + assert.True(t, strings.HasPrefix(bash, `{"*":"allow",`), "the catch-all comes first, because the last matching rule wins: %s", bash) + for _, denied := range []string{"git push*", "git commit*", "git reset*", "sudo *", "rm -rf /*"} { + assert.Contains(t, bash, `"`+denied+`":"deny"`) + } } func TestVersionAtLeast(t *testing.T) { diff --git a/internal/setup/agent/opencode_config.go b/internal/setup/agent/opencode_config.go index c612473ec..8b35363f9 100644 --- a/internal/setup/agent/opencode_config.go +++ b/internal/setup/agent/opencode_config.go @@ -9,18 +9,25 @@ import ( // it in OPENCODE_CONFIG_CONTENT, which overrides the user's own OpenCode // configuration for this process only. // -// The permission rules let OpenCode read and edit files in the project without -// asking. Every shell command needs the user's approval. Rules deny access to -// files outside the project, and they deny web access and sub-agents. +// The agent works without approvals: it reads, edits, and runs shell commands +// in the project without asking. It asks the user only with the question +// tool, when it needs information such as where a key is stored. Rules deny +// files outside the project, web access, sub-agents, and a few commands that +// the agent must never run, such as git push. func openCodeConfig() ([]byte, error) { + bash, err := orderedRules(bashRules) + if err != nil { + return nil, err + } return json.Marshal(map[string]any{ "$schema": "https://opencode.ai/config.json", - "permission": map[string]string{ + "permission": map[string]any{ "read": "allow", "glob": "allow", "grep": "allow", "edit": "allow", - "bash": "ask", + "bash": bash, + "question": "allow", "external_directory": "deny", "webfetch": "deny", "websearch": "deny", @@ -29,6 +36,44 @@ func openCodeConfig() ([]byte, error) { }) } +// bashRules are the shell command rules, in order. OpenCode applies the last +// rule that matches, so the catch-all allow comes first and the denials +// after it. +var bashRules = [][2]string{ + {"*", "allow"}, + {"git commit*", "deny"}, + {"git push*", "deny"}, + {"git reset*", "deny"}, + {"sudo *", "deny"}, + {"rm -rf /*", "deny"}, + {"rm -fr /*", "deny"}, +} + +// orderedRules encodes rules as a JSON object with the keys in the given +// order. A Go map would sort the keys. +func orderedRules(rules [][2]string) (json.RawMessage, error) { + var b strings.Builder + b.WriteByte('{') + for i, r := range rules { + if i > 0 { + b.WriteByte(',') + } + key, err := json.Marshal(r[0]) + if err != nil { + return nil, err + } + value, err := json.Marshal(r[1]) + if err != nil { + return nil, err + } + b.Write(key) + b.WriteByte(':') + b.Write(value) + } + b.WriteByte('}') + return json.RawMessage(b.String()), nil +} + // childEnv returns the environment for the OpenCode process. It removes every // LD_ variable, so OpenCode cannot read the ldcli access token or other ldcli // settings, and it removes the variables that the CLI sets itself. From 3b754162e2750fd70f379c0c9b70cf24414d5b34 Mon Sep 17 00:00:00 2001 From: Anthony Torns II Date: Mon, 19 Oct 2026 20:31:44 -0700 Subject: [PATCH 14/16] feat(setup): run the setup agent through the Go AI SDK with an OpenCode handler The setup agent is now an AI SDK agent. For a logged-in user, setup calls aiserver.Config("ldcli-setup-agent").Stream with a new OpenCode handler. The AI SDK resolves the config through the relay, composes the prompt from it, and calls the handler. The handler sends that prompt to the OpenCode session and turns the session's events into the SDK's stream events. The SDK records the run: an opencode.session span, a chat span per reply with its tokens, an execute_tool span per tool call, and the tokens, duration, and success events for the config's Monitoring tab. The handler implements the AI SDK's handler contract for agent-mode configs of any provider, so it can move into go-ai-sdk unchanged. The wizard still gets every OpenCode event, including questions and changed files. If the SDK cannot resolve the config before the handler starts, the run uses the built-in instructions and task. Closing a run that ended now waits up to 5 seconds for the SDK to record it. Before, the wizard closed the run at once and cut the tracking off. Co-Authored-By: Claude Opus 5.5 --- cmd/setup/setup.go | 3 + go.mod | 6 +- internal/setup/agent/aiconfig.go | 47 ++-- internal/setup/agent/opencode.go | 174 ++++++++++++++- internal/setup/agent/opencode_handler.go | 200 ++++++++++++++++++ internal/setup/agent/opencode_handler_test.go | 180 ++++++++++++++++ 6 files changed, 580 insertions(+), 30 deletions(-) create mode 100644 internal/setup/agent/opencode_handler.go create mode 100644 internal/setup/agent/opencode_handler_test.go diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index 91b34d06b..f79eb4f42 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -79,6 +79,9 @@ func withConfigSource(runner *agent.OpenCode, run func(*cobra.Command, []string) viper.GetString(cliflags.AccessTokenFlag), ) runner.Config = source + // A logged-in user's agent runs through the AI SDK, so the run is + // recorded against the setup-agent AI Config. + runner.SDK, _ = source.(*agent.AIConfigSource) if closer, ok := source.(interface{ Close(context.Context) error }); ok { defer func() { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) diff --git a/go.mod b/go.mod index cd695c6d8..d487fa301 100644 --- a/go.mod +++ b/go.mod @@ -45,6 +45,9 @@ require ( github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.12.1 + go.opentelemetry.io/otel v1.47.0 + go.opentelemetry.io/otel/sdk v1.47.0 + go.opentelemetry.io/otel/trace v1.47.0 go.uber.org/mock v0.5.2 golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa golang.org/x/term v0.46.0 @@ -127,13 +130,10 @@ require ( github.com/yuin/goldmark v1.7.8 // indirect github.com/yuin/goldmark-emoji v1.0.5 // 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 diff --git a/internal/setup/agent/aiconfig.go b/internal/setup/agent/aiconfig.go index 12e2197fe..5977df839 100644 --- a/internal/setup/agent/aiconfig.go +++ b/internal/setup/agent/aiconfig.go @@ -175,14 +175,27 @@ func initQuietClient(ctx context.Context, opts aiserver.InitOptions) error { return nil } -func (s *AIConfigSource) Resolve(ctx context.Context, task Task) (Config, error) { +// Ready starts the SDK behind the relay proxy, once, and reports whether it +// started. +func (s *AIConfigSource) Ready(ctx context.Context) error { s.once.Do(func() { s.initErr = s.start(ctx) if s.initErr != nil { s.closeProxy(ctx) } }) - if s.initErr != nil { + return s.initErr +} + +func (s *AIConfigSource) key() string { + if s.Key == "" { + return SetupAgentConfigKey + } + return s.Key +} + +func (s *AIConfigSource) Resolve(ctx context.Context, task Task) (Config, error) { + if s.Ready(ctx) != nil { return s.fallback(ctx, task) } @@ -190,11 +203,7 @@ func (s *AIConfigSource) Resolve(ctx context.Context, task Task) (Config, error) if inspect == nil { inspect = aiserver.InspectConfig } - key := s.Key - if key == "" { - key = SetupAgentConfigKey - } - res := inspect(ctx, key, setupContext(task)) + res := inspect(ctx, s.key(), setupContext(task)) if !res.Enabled || res.Config == nil { return s.fallback(ctx, task) } @@ -278,17 +287,23 @@ func configFromAI(c *aiserver.AiConfig, task Task) Config { } else { cfg.Task = BuildFacts(task) } - provider := strings.ToLower(strings.TrimSpace(c.Provider.Name)) - if mapped, ok := openCodeProviders[provider]; ok { - provider = mapped + cfg.ProviderID, cfg.ModelID = openCodeModel(*c) + return cfg +} + +// openCodeModel returns the OpenCode provider ID and model ID for a config. +// OpenCode needs both values to pick a model, so if the config has only one, +// both are empty and OpenCode uses its default model. +func openCodeModel(c aiserver.AiConfig) (providerID, modelID string) { + providerID = strings.ToLower(strings.TrimSpace(c.Provider.Name)) + if mapped, ok := openCodeProviders[providerID]; ok { + providerID = 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 = "", "" + modelID = c.Model.Name + if providerID == "" || modelID == "" { + return "", "" } - return cfg + return providerID, modelID } // RelayError is a refusal from the relay, for example because the kill switch diff --git a/internal/setup/agent/opencode.go b/internal/setup/agent/opencode.go index 9c10292d7..8c90baa51 100644 --- a/internal/setup/agent/opencode.go +++ b/internal/setup/agent/opencode.go @@ -11,6 +11,8 @@ import ( "strings" "sync" "time" + + "github.com/launchdarkly/go-ai-sdk/aiserver" ) // MinOpenCodeVersion is the oldest OpenCode version that the adapter was @@ -21,8 +23,12 @@ const MinOpenCodeVersion = "1.19.0" type OpenCode struct { // Binary is the OpenCode executable. The default is "opencode" on the PATH. Binary string - // Config gives the model and the instructions for each run. + // Config gives the model and the instructions for each run when SDK is + // nil or cannot start. Config ConfigSource + // SDK runs the agent through the AI SDK: the SDK resolves the setup-agent + // AI Config, calls the OpenCode handler, and records the run. + SDK *AIConfigSource // lookPath and version are fields so that tests can replace them. lookPath func(string) (string, error) @@ -69,6 +75,9 @@ func (o *OpenCode) Check(ctx context.Context) error { } func (o *OpenCode) Start(ctx context.Context, task Task) (Run, error) { + if o.SDK != nil && o.SDK.Ready(ctx) == nil { + return o.startWithSDK(ctx, task) + } source := o.Config if source == nil { source = DefaultConfigSource{} @@ -95,17 +104,101 @@ func (o *OpenCode) Start(ctx context.Context, task Task) (Run, error) { srv.stop() return nil, err } - run.stopServer = srv.stop + run.watch(srv) + return run, nil +} + +// startWithSDK runs the agent through the AI SDK. The SDK evaluates the +// setup-agent AI Config, composes the prompt from it, and calls the OpenCode +// handler, which sends that prompt to this run's session. The SDK then +// records the run, its tool calls, and its tokens against the variation. +// +// If the SDK cannot resolve the config before the handler starts, for +// example because the config is off or is not in agent mode, the run uses the +// built-in instructions and task. +func (o *OpenCode) startWithSDK(ctx context.Context, task Task) (Run, error) { + config, err := openCodeConfig() + if err != nil { + return nil, err + } + srv, err := startServer(ctx, o.binary(), task.Dir, config) + if err != nil { + return nil, err + } + run, err := startSession(ctx, srv.client) + if err != nil { + srv.stop() + return nil, err + } + run.watch(srv) + + sdkCtx, cancel := context.WithCancel(context.Background()) + run.cancelSDK = cancel + run.sdkDone = make(chan struct{}) + go run.driveWithSDK(sdkCtx, o.SDK, task) + return run, nil +} + +// driveWithSDK runs the AI SDK stream for the run. The run's own event channel +// still gets every event, so the wizard shows the same progress as without the +// SDK. +func (r *openCodeRun) driveWithSDK(ctx context.Context, sdk *AIConfigSource, task Task) { + if r.sdkDone != nil { + defer close(r.sdkDone) + } + var mu sync.Mutex + started := false + handler := NewOpenCodeHandler(func(ctx context.Context, p SessionPrompt) (<-chan Event, error) { + mu.Lock() + started = true + mu.Unlock() + cfg := Config{Instructions: p.System, ProviderID: p.ProviderID, ModelID: p.ModelID} + text := p.Text + if cfg.Instructions == "" { + cfg.Instructions, text = DefaultInstructions, BuildPrompt(task) + } + tap := r.startTap() + if err := r.client.promptAsync(ctx, r.sessionID, cfg, text); err != nil { + return nil, fmt.Errorf("could not send the task to OpenCode: %w", err) + } + return tap, nil + }) + for _, err := range aiserver.Config(aiserver.ConfigArgs{ + Key: sdk.key(), + Handlers: []*aiserver.ProviderHandler{handler}, + SkipJudges: true, + }).Stream(ctx, BuildFacts(task), setupContext(task)) { + if err == nil { + continue + } + mu.Lock() + handlerStarted := started + mu.Unlock() + if handlerStarted { + // The handler already sent the failure to the run's events. + return + } + cfg, _ := DefaultConfigSource{}.Resolve(ctx, task) + if perr := r.client.promptAsync(ctx, r.sessionID, cfg, BuildPrompt(task)); perr != nil { + r.fail(fmt.Sprintf("could not send the task to OpenCode: %v", perr)) + } + return + } +} + +// watch ends the run's events if OpenCode exits during the run, so the caller +// gets an error and does not wait forever. +func (r *openCodeRun) watch(srv *server) { + r.stopServer = srv.stop go func() { // If OpenCode exits during the run, end the event stream so the caller // gets an error and does not wait forever. select { case <-srv.exited: - run.fail(fmt.Sprintf("OpenCode stopped during the run: %s", srv.output.String())) - case <-run.closed: + r.fail(fmt.Sprintf("OpenCode stopped during the run: %s", srv.output.String())) + case <-r.closed: } }() - return run, nil } // openCodeRun is a Run on an OpenCode server. @@ -116,14 +209,34 @@ type openCodeRun struct { cancel context.CancelFunc closed chan struct{} stopServer func() + // cancelSDK stops the AI SDK stream of a run that started with the SDK, + // and sdkDone closes when that stream ends. + cancelSDK context.CancelFunc + sdkDone chan struct{} - mu sync.Mutex - finished bool + mu sync.Mutex + finished bool + // tap gets a copy of every event, for the OpenCode handler. + tap chan Event closeOnce sync.Once } // startRun creates a session, subscribes to its events, and sends the task. func startRun(ctx context.Context, c *client, cfg Config, prompt string) (*openCodeRun, error) { + r, err := startSession(ctx, c) + if err != nil { + return nil, err + } + if err := c.promptAsync(ctx, r.sessionID, cfg, prompt); err != nil { + r.Close() + return nil, fmt.Errorf("could not send the task to OpenCode: %w", err) + } + return r, nil +} + +// startSession creates a session and subscribes to its events. It sends no +// task. +func startSession(ctx context.Context, c *client) (*openCodeRun, error) { sessionID, err := c.createSession(ctx) if err != nil { return nil, fmt.Errorf("could not create an OpenCode session: %w", err) @@ -143,12 +256,19 @@ func startRun(ctx context.Context, c *client, cfg Config, prompt string) (*openC closed: make(chan struct{}), } go r.pump(stream) + return r, nil +} - if err := c.promptAsync(ctx, sessionID, cfg, prompt); err != nil { - r.Close() - return nil, fmt.Errorf("could not send the task to OpenCode: %w", err) +// startTap returns a channel that gets a copy of every later event. It closes +// after the last event. +func (r *openCodeRun) startTap() <-chan Event { + r.mu.Lock() + defer r.mu.Unlock() + r.tap = make(chan Event, 256) + if r.finished { + close(r.tap) } - return r, nil + return r.tap } func (r *openCodeRun) pump(stream io.ReadCloser) { @@ -190,13 +310,33 @@ func (r *openCodeRun) send(ev Event) bool { case <-r.closed: return false } + if r.tap != nil { + select { + case r.tap <- ev: + case <-r.closed: + return false + } + } if ev.Kind == EventDone || ev.Kind == EventError { r.finished = true close(r.events) + if r.tap != nil { + close(r.tap) + } } return true } +// sdkFinishTimeout is how long Close waits for the AI SDK to record a run that +// ended. +const sdkFinishTimeout = 5 * time.Second + +func (r *openCodeRun) isFinished() bool { + r.mu.Lock() + defer r.mu.Unlock() + return r.finished +} + func (r *openCodeRun) fail(msg string) { r.send(Event{Kind: EventError, Text: msg}) } @@ -213,8 +353,20 @@ func (r *openCodeRun) Answer(ctx context.Context, req QuestionRequest, answers [ func (r *openCodeRun) Close() error { r.closeOnce.Do(func() { + // Let the AI SDK finish recording a run that ended, so its tokens, + // duration, and spans reach LaunchDarkly. A run that is still going + // is stopped at once. + if r.sdkDone != nil && r.isFinished() { + select { + case <-r.sdkDone: + case <-time.After(sdkFinishTimeout): + } + } close(r.closed) r.cancel() + if r.cancelSDK != nil { + r.cancelSDK() + } if r.stopServer != nil { r.stopServer() } diff --git a/internal/setup/agent/opencode_handler.go b/internal/setup/agent/opencode_handler.go new file mode 100644 index 000000000..3b8bd0b80 --- /dev/null +++ b/internal/setup/agent/opencode_handler.go @@ -0,0 +1,200 @@ +package agent + +import ( + "context" + "errors" + "iter" + "strings" + + "go.opentelemetry.io/otel/trace" + + "github.com/launchdarkly/go-ai-sdk/aiserver" +) + +// The OpenCode handler is an AI SDK provider handler, like the Claude and +// OpenAI agent handlers in go-ai-sdk. The AI SDK resolves the AI Config and +// calls the handler, the handler runs one OpenCode session through +// SessionEvents, and the AI SDK records the run against the variation. It is +// written so that it can move into go-ai-sdk as its own package with the same +// contract. +const ( + // HandlerTracer is the handler's instrumentation scope. The AI SDK + // requires it to start with aiserver.TracerPrefix. + HandlerTracer = aiserver.TracerPrefix + "/opencodeagents" + // HandlerSpanName is the name of the root span of one run. + HandlerSpanName = "opencode.session" +) + +// SessionPrompt is what the handler sends to OpenCode for one run. +type SessionPrompt struct { + // System is the system prompt from the config. + System string + // ProviderID and ModelID select the OpenCode model. Both are empty if the + // config names no model, and then OpenCode uses its default model. + ProviderID string + ModelID string + // Text is the user message. + Text string +} + +// SessionEvents runs one OpenCode session for the prompt and returns its +// events. The channel must end after EventDone or EventError. +type SessionEvents func(ctx context.Context, p SessionPrompt) (<-chan Event, error) + +// NewOpenCodeHandler returns an AI SDK handler for agent-mode configs of any +// provider. The caller owns the OpenCode process and supplies its events. +// +// The handler does not run judges. A config that has judges must be called +// with SkipJudges, because the handler would also be chosen for the judge +// configs, and it cannot run a judge prompt. +func NewOpenCodeHandler(events SessionEvents) *aiserver.ProviderHandler { + h := openCodeHandler{events: events} + return aiserver.CreateHandlerInternal( + aiserver.ProvidesFor{Provider: aiserver.WildcardProvider, Mode: aiserver.ModeAgent}, + h.invoke, h.stream, false, + ) +} + +type openCodeHandler struct { + events SessionEvents +} + +func (h openCodeHandler) invoke(ctx context.Context, req aiserver.HandlerRequest) (aiserver.HandlerResult, error) { + var res aiserver.HandlerResult + for ev, err := range h.stream(ctx, req) { + if err != nil { + return res, err + } + if ev.Type == aiserver.EventDone { + if ev.Output != nil { + res.Output = *ev.Output + } + res.Usage = ev.Usage + } + } + return res, nil +} + +func (h openCodeHandler) stream(ctx context.Context, req aiserver.HandlerRequest) iter.Seq2[aiserver.HandlerStreamEvent, error] { + return func(yield func(aiserver.HandlerStreamEvent, error) bool) { + p := aiserver.ComposePrompt(req, aiserver.ModeAgent) + providerID, modelID := openCodeModel(req.Config) + system := providerID + if system == "" { + system = "opencode" + } + run := aiserver.StartHandlerRun(ctx, aiserver.HandlerSpanOptions{ + Tracer: HandlerTracer, + Name: HandlerSpanName, + System: system, + Operation: "invoke_agent", + RequestModel: modelID, + }, req, p) + defer run.EndOnPanic() + tools := toolSpans{run: run, open: map[string]trace.Span{}} + + fail := func(err error) { + tools.endAll(err) + run.Fail(err) + yield(aiserver.HandlerStreamEvent{}, err) + } + + events, err := h.events(run.Context(), SessionPrompt{ + System: p.System, + ProviderID: providerID, + ModelID: modelID, + Text: userText(p), + }) + if err != nil { + fail(err) + return + } + + var output strings.Builder + var usage aiserver.SpanUsage + responseModel := modelID + for ev := range events { + switch ev.Kind { + case EventText: + output.WriteString(ev.Text) + if !yield(aiserver.HandlerStreamEvent{Type: aiserver.EventChunk, Text: ev.Text}, nil) { + tools.endAll(nil) + run.Abandon() + return + } + case EventTool: + tools.update(ev.Tool) + case EventUsage: + // OpenCode reports the usage of each finished reply, so each one + // becomes a chat span with its own token counts. + turn := aiserver.SpanUsage{Input: float64(ev.Usage.InputTokens), Output: float64(ev.Usage.OutputTokens)} + if ev.Usage.ModelID != "" { + responseModel = ev.Usage.ModelID + } + run.EndChat(run.StartChat(responseModel), responseModel, turn, "") + usage.Add(turn) + case EventDone: + tools.endAll(nil) + text := output.String() + run.Finish(responseModel, text) + yield(aiserver.HandlerStreamEvent{ + Type: aiserver.EventDone, + Output: &text, + Usage: map[string]any{"input": usage.Input, "output": usage.Output}, + }, nil) + return + case EventError: + fail(errors.New(ev.Text)) + return + } + } + fail(errors.New("the OpenCode session ended without a result")) + } +} + +// userText is the user message for OpenCode: the last user turn of the +// composed prompt. OpenCode keeps the conversation itself, so the handler +// sends only the new message. +func userText(p aiserver.Prompt) string { + for i := len(p.Turns) - 1; i >= 0; i-- { + if p.Turns[i].Role == "user" { + return aiserver.ContentText(p.Turns[i]) + } + } + return "" +} + +// toolSpans keeps one execute_tool span open for each running tool call. +type toolSpans struct { + run *aiserver.HandlerRun + open map[string]trace.Span +} + +func (t *toolSpans) update(tc ToolCall) { + if _, ok := t.open[tc.ID]; !ok { + t.open[tc.ID] = t.run.StartTool(tc.Name, tc.ID) + } + switch tc.Status { + case "completed": + t.end(tc.ID, nil) + case "error": + msg := tc.Title + if msg == "" { + msg = tc.Name + " failed" + } + t.end(tc.ID, errors.New(msg)) + } +} + +func (t *toolSpans) end(id string, err error) { + if span, ok := t.open[id]; ok { + t.run.EndTool(span, err) + delete(t.open, id) + } +} + +func (t *toolSpans) endAll(err error) { + for id := range t.open { + t.end(id, err) + } +} diff --git a/internal/setup/agent/opencode_handler_test.go b/internal/setup/agent/opencode_handler_test.go new file mode 100644 index 000000000..5df1dd5b1 --- /dev/null +++ b/internal/setup/agent/opencode_handler_test.go @@ -0,0 +1,180 @@ +package agent + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" + + "github.com/launchdarkly/go-ai-sdk/aiserver" +) + +func handlerRequest() aiserver.HandlerRequest { + cfg := aiserver.AiConfig{Instructions: "Wire the AI call.", Model: aiserver.Model{Name: "claude-sonnet-5"}} + cfg.Provider.Name = "Anthropic" + return aiserver.HandlerRequest{Config: cfg, UserInput: "Facts from ldcli setup"} +} + +func eventsOf(evs ...Event) <-chan Event { + ch := make(chan Event, len(evs)) + for _, e := range evs { + ch <- e + } + close(ch) + return ch +} + +func TestOpenCodeHandler_StreamsAndRecordsTheRun(t *testing.T) { + spans := tracetest.NewSpanRecorder() + prev := otel.GetTracerProvider() + otel.SetTracerProvider(sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(spans))) + defer otel.SetTracerProvider(prev) + + var got SessionPrompt + h := NewOpenCodeHandler(func(_ context.Context, p SessionPrompt) (<-chan Event, error) { + got = p + return eventsOf( + Event{Kind: EventTool, Tool: ToolCall{ID: "c1", Name: "edit", Status: "running"}}, + Event{Kind: EventText, Text: "Wired "}, + Event{Kind: EventTool, Tool: ToolCall{ID: "c1", Name: "edit", Status: "completed"}}, + Event{Kind: EventUsage, Usage: Usage{ModelID: "claude-sonnet-5", InputTokens: 100, OutputTokens: 20}}, + Event{Kind: EventText, Text: "main.py."}, + Event{Kind: EventUsage, Usage: Usage{ModelID: "claude-sonnet-5", InputTokens: 50, OutputTokens: 10}}, + Event{Kind: EventDone}, + ), nil + }) + + var chunks []string + var done aiserver.HandlerStreamEvent + for ev, err := range h.Stream(context.Background(), handlerRequest()) { + require.NoError(t, err) + if ev.Type == aiserver.EventChunk { + chunks = append(chunks, ev.Text) + } else { + done = ev + } + } + + assert.Equal(t, SessionPrompt{System: "Wire the AI call.", ProviderID: "anthropic", ModelID: "claude-sonnet-5", Text: "Facts from ldcli setup"}, got) + assert.Equal(t, []string{"Wired ", "main.py."}, chunks) + require.Equal(t, aiserver.EventDone, done.Type) + assert.Equal(t, "Wired main.py.", *done.Output) + assert.Equal(t, aiserver.TokenUsage{Input: 150, Output: 30, Total: 180}, aiserver.ParseUsage(done.Usage)) + + var names []string + for _, s := range spans.Ended() { + names = append(names, s.Name()) + } + assert.ElementsMatch(t, []string{HandlerSpanName, "execute_tool edit", "chat claude-sonnet-5", "chat claude-sonnet-5"}, names) +} + +func TestOpenCodeHandler_ErrorEndsTheRun(t *testing.T) { + h := NewOpenCodeHandler(func(context.Context, SessionPrompt) (<-chan Event, error) { + return eventsOf(Event{Kind: EventError, Text: "no API key"}), nil + }) + + var gotErr error + for _, err := range h.Stream(context.Background(), handlerRequest()) { + if err != nil { + gotErr = err + } + } + + require.Error(t, gotErr) + assert.Contains(t, gotErr.Error(), "no API key") +} + +func TestOpenCodeHandler_StreamWithoutAnEndIsAnError(t *testing.T) { + h := NewOpenCodeHandler(func(context.Context, SessionPrompt) (<-chan Event, error) { + return eventsOf(Event{Kind: EventText, Text: "partial"}), nil + }) + + var gotErr error + for _, err := range h.Stream(context.Background(), handlerRequest()) { + if err != nil { + gotErr = err + } + } + + assert.Error(t, gotErr) +} + +// TestRun_ThroughTheAISDK runs the agent the way setup does for a logged-in +// user: the real AI SDK behind the proxy reads the config from a fake relay, +// calls the OpenCode handler, and the handler drives a fake OpenCode session. +func TestRun_ThroughTheAISDK(t *testing.T) { + var mu sync.Mutex + var bodies []string + relay := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == RelayPath+"/polling/sdk/latest-all" { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"flags":{"ldcli-setup-agent":`+strings.ReplaceAll(setupAgentFlag, "\n", "")+`},"segments":{}}`) + return + } + body, _ := io.ReadAll(r.Body) + mu.Lock() + bodies = append(bodies, r.URL.Path+" "+string(body)) + mu.Unlock() + w.WriteHeader(http.StatusAccepted) + })) + defer relay.Close() + sdk := NewAIConfigSource(relay.URL, "api-123").(*AIConfigSource) + require.NoError(t, sdk.Ready(context.Background())) + + oc := newFakeOpenCode(t) + run, err := startSession(context.Background(), oc.client()) + require.NoError(t, err) + ctx, cancel := context.WithCancel(context.Background()) + run.cancelSDK = cancel + run.sdkDone = make(chan struct{}) + go run.driveWithSDK(ctx, sdk, Task{SDKID: "python-server-sdk", FlagKey: "my-new-flag"}) + + require.Eventually(t, func() bool { + oc.mu.Lock() + defer oc.mu.Unlock() + return oc.prompt != nil + }, 5*time.Second, 10*time.Millisecond, "the handler sends the prompt") + oc.mu.Lock() + assert.Equal(t, "From AgentControl.", oc.prompt["system"], "the system prompt comes from the config") + assert.Equal(t, map[string]any{"providerID": "anthropic", "modelID": "claude-sonnet-5"}, oc.prompt["model"]) + oc.mu.Unlock() + + oc.emit(`{"type":"message.updated","properties":{"info":{"id":"m1","sessionID":"ses","role":"assistant","providerID":"anthropic","modelID":"claude-sonnet-5","time":{"created":1,"completed":2},"tokens":{"input":100,"output":20}}}}`) + oc.emit(`{"type":"session.idle","properties":{"sessionID":"ses"}}`) + + var kinds []EventKind + for ev := range run.Events() { + kinds = append(kinds, ev.Kind) + } + assert.Equal(t, []EventKind{EventUsage, EventDone}, kinds, "the wizard still gets every event") + + require.NoError(t, run.Close()) + require.NoError(t, sdk.Close(context.Background())) + mu.Lock() + defer mu.Unlock() + all := strings.Join(bodies, "\n") + for _, b := range bodies { + path, rest, _ := strings.Cut(b, " ") + var kinds []string + for _, k := range []string{"$ld:ai:feedback", "$ld:ai:tokens:total", "$ld:ai:generation:success", "$ld:ai:duration:total", "$ld:ai:sdk:info", "$ld:ai:usage"} { + if strings.Contains(rest, k) { + kinds = append(kinds, k) + } + } + t.Logf("relay got %s (%d bytes) %v", path, len(rest), kinds) + } + assert.Contains(t, all, RelayPath+"/events/bulk", "the SDK sends its events through the relay") + assert.Contains(t, all, "$ld:ai:tokens:total", "the run's tokens are tracked against the AI Config") + assert.Contains(t, all, "$ld:ai:generation:success", "the run's result is tracked") + assert.Contains(t, all, RelayPath+"/otel/v1/traces", "the run's spans go out through the relay") +} From ddc4e9d26aa3e447ca19ec6aec15c7f1afbd9cd7 Mon Sep 17 00:00:00 2001 From: Anthony Torns II Date: Mon, 19 Oct 2026 20:54:05 -0700 Subject: [PATCH 15/16] fix(setup): stop OpenCode when the terminal closes A closed terminal sends SIGHUP, which ended ldcli at once and left the OpenCode process running. The wizard now quits on SIGHUP the same way that Bubble Tea quits on SIGINT and SIGTERM, so it stops OpenCode. `ldcli setup agent` stops OpenCode on SIGINT, SIGTERM, and SIGHUP. Co-Authored-By: Claude Opus 5.5 --- cmd/setup/agent.go | 4 +++- cmd/setup/model.go | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/cmd/setup/agent.go b/cmd/setup/agent.go index f37957257..80b7cee1a 100644 --- a/cmd/setup/agent.go +++ b/cmd/setup/agent.go @@ -11,6 +11,7 @@ import ( "sort" "strconv" "strings" + "syscall" "github.com/spf13/cobra" @@ -151,7 +152,8 @@ func runAgent(svc setup.Service, runner agent.Runner) func(*cobra.Command, []str } task := agentTask(dir, detection, getFlag(cmd, flagKeyFlag), setup.IsInstalled(dir, sdkID)) - ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt) + // Stop OpenCode on Ctrl-C, on kill, and when the terminal closes. + ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM, syscall.SIGHUP) defer stop() if err := runner.Check(ctx); err != nil { diff --git a/cmd/setup/model.go b/cmd/setup/model.go index a22b0c44f..1eeb33206 100644 --- a/cmd/setup/model.go +++ b/cmd/setup/model.go @@ -2,7 +2,10 @@ package setup import ( "io" + "os" + "os/signal" "sync" + "syscall" "github.com/atotto/clipboard" "github.com/charmbracelet/bubbles/list" @@ -267,6 +270,17 @@ func runSetupWizard( } p := tea.NewProgram(m, tea.WithAltScreen()) + // Bubble Tea quits on SIGINT and SIGTERM. A closed terminal sends + // SIGHUP, which would end ldcli at once and leave OpenCode running, so + // it quits the wizard the same way. + hangup := make(chan os.Signal, 1) + signal.Notify(hangup, syscall.SIGHUP) + defer signal.Stop(hangup) + go func() { + if _, ok := <-hangup; ok { + p.Quit() + } + }() _, err = p.Run() // The wizard closes a run when the user quits, but a run can finish // starting after that. OpenCode must not outlive setup. From c86a36b637b256111af00834be2d1ef176569c43 Mon Sep 17 00:00:00 2001 From: Anthony Torns II Date: Mon, 19 Oct 2026 22:31:28 -0700 Subject: [PATCH 16/16] feat(setup): run the setup agent in a macOS sandbox The agent runs shell commands without approvals, and OpenCode's rules limit only its file tools. In a test run, the agent searched the whole disk with find, read files in another repo, and read the ldcli access token with `ldcli config get`. On macOS, ldcli now starts OpenCode with sandbox-exec and a Seatbelt profile for each run. The sandbox applies to OpenCode and to every command that it runs. It denies the home folder, except the project, OpenCode's own folders, package caches, and toolchains. Other repos, ~/.config/ldcli, and ~/.ssh stay unreadable. System folders and the network stay open. The profile uses resolved paths, because Seatbelt matches the real path behind a symlink. OpenCode's rules also deny `ldcli` and `npx skills` now. The CLI does the LaunchDarkly work, and skills must not be installed into the repo. Linux and Windows run without a sandbox for now. Co-Authored-By: Claude Opus 5.5 --- internal/setup/agent/opencode_config.go | 4 + internal/setup/agent/sandbox.go | 230 ++++++++++++++++++++++++ internal/setup/agent/sandbox_test.go | 108 +++++++++++ internal/setup/agent/server.go | 16 +- 4 files changed, 354 insertions(+), 4 deletions(-) create mode 100644 internal/setup/agent/sandbox.go create mode 100644 internal/setup/agent/sandbox_test.go diff --git a/internal/setup/agent/opencode_config.go b/internal/setup/agent/opencode_config.go index 8b35363f9..70d348855 100644 --- a/internal/setup/agent/opencode_config.go +++ b/internal/setup/agent/opencode_config.go @@ -41,6 +41,10 @@ func openCodeConfig() ([]byte, error) { // after it. var bashRules = [][2]string{ {"*", "allow"}, + // ldcli and the skills installer reach LaunchDarkly or change the repo + // outside the agent's task. The CLI does the LaunchDarkly work itself. + {"ldcli*", "deny"}, + {"npx skills*", "deny"}, {"git commit*", "deny"}, {"git push*", "deny"}, {"git reset*", "deny"}, diff --git a/internal/setup/agent/sandbox.go b/internal/setup/agent/sandbox.go new file mode 100644 index 000000000..58a4af588 --- /dev/null +++ b/internal/setup/agent/sandbox.go @@ -0,0 +1,230 @@ +package agent + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +// The setup agent runs commands without approvals, and OpenCode's rules limit +// only its file tools, not the paths that a shell command touches. So on +// macOS, ldcli starts OpenCode in a Seatbelt sandbox (sandbox-exec), which +// applies to OpenCode and to every command that it runs. The sandbox denies +// the user's home folder, except the project, OpenCode's own folders, package +// caches, and toolchains. Other repos, ~/.config/ldcli, and ~/.ssh stay +// unreadable. System folders and the network stay open, because the model API +// and the package registries need them. + +const sandboxExec = "/usr/bin/sandbox-exec" + +// sandboxPaths are the folders under the home folder that the sandbox opens. +type sandboxPaths struct { + Home string + // ReadWrite are folders that the agent and its commands can change. + ReadWrite []string + // ReadOnly are folders that commands must read but not change. + ReadOnly []string +} + +// readWriteUnderHome are package caches and OpenCode's own folders, relative to +// the home folder. A package install writes to its cache, and OpenCode writes +// its sessions and snapshots. +var readWriteUnderHome = []string{ + ".local/share/opencode", + ".local/state/opencode", + ".cache/opencode", + ".cache/uv", + ".cache/pip", + ".cache/pypoetry", + ".cache/go-build", + ".npm", + ".bun/install/cache", + ".gradle", + ".m2", + ".cargo/registry", + ".cargo/git", + "go/pkg/mod", + "Library/Caches/pip", + "Library/Caches/pypoetry", + "Library/Caches/go-build", + "Library/Caches/Yarn", + "Library/Caches/pnpm", + "Library/pnpm", +} + +// readOnlyUnderHome are toolchains and settings that commands read. +var readOnlyUnderHome = []string{ + ".config/opencode", + ".local/bin", + ".local/share/uv", + ".local/share/mise", + ".pyenv", + ".rbenv", + ".asdf", + ".nvm", + ".volta", + ".bun/bin", + ".deno", + ".rustup", + ".cargo/bin", + "go/bin", + ".sdkman", + ".gitconfig", + ".config/git", +} + +// buildSandboxPaths returns the folders to open for a run in dir. opencodeDir +// is the folder that holds the OpenCode program, so a copy installed under +// the home folder can still start. +func buildSandboxPaths(home, dir, opencodeDir string, env []string) sandboxPaths { + p := sandboxPaths{Home: home, ReadWrite: []string{dir}} + for _, rel := range readWriteUnderHome { + p.ReadWrite = append(p.ReadWrite, filepath.Join(home, rel)) + } + for _, rel := range readOnlyUnderHome { + p.ReadOnly = append(p.ReadOnly, filepath.Join(home, rel)) + } + // OpenCode follows the XDG folder variables. If they point elsewhere, + // open only OpenCode's folder in each. + for name, mode := range map[string]string{"XDG_DATA_HOME": "rw", "XDG_STATE_HOME": "rw", "XDG_CACHE_HOME": "rw", "XDG_CONFIG_HOME": "ro"} { + value := envValue(env, name) + if value == "" { + continue + } + folder := filepath.Join(value, "opencode") + if mode == "rw" { + p.ReadWrite = append(p.ReadWrite, folder) + } else { + p.ReadOnly = append(p.ReadOnly, folder) + } + } + if opencodeDir != "" { + p.ReadOnly = append(p.ReadOnly, opencodeDir) + } + return p +} + +// seatbeltProfile returns the sandbox profile. Later rules take precedence, +// so the allow rules for the open folders come after the deny rule for the +// home folder. +func seatbeltProfile(p sandboxPaths) string { + var b strings.Builder + b.WriteString("(version 1)\n(allow default)\n") + fmt.Fprintf(&b, "(deny file-read* file-write* (subpath %s))\n", sbString(p.Home)) + // Tools read the metadata of the home folder itself, for example to + // resolve a path, without reading what is in it. + fmt.Fprintf(&b, "(allow file-read-metadata (literal %s))\n", sbString(p.Home)) + for _, path := range p.ReadOnly { + fmt.Fprintf(&b, "(allow file-read* (subpath %s))\n", sbString(path)) + } + for _, path := range p.ReadWrite { + fmt.Fprintf(&b, "(allow file-read* file-write* (subpath %s))\n", sbString(path)) + } + return b.String() +} + +// sbString quotes a path for a Seatbelt profile. +func sbString(s string) string { + return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"` +} + +func envValue(env []string, name string) string { + for _, kv := range env { + if k, v, ok := strings.Cut(kv, "="); ok && k == name { + return v + } + } + return "" +} + +// installDir returns the folder to open for the OpenCode program at path. The +// program is often a launcher in the bin folder of its own install, such as +// .../node_modules/opencode-ai/bin, so the folder above bin holds the rest of +// the install. A shared bin folder, such as ~/.local/bin, opens only itself, +// so other programs' data stays closed. +func installDir(home, path string) string { + bin := filepath.Dir(path) + if filepath.Base(bin) != "bin" { + return bin + } + root := filepath.Dir(bin) + shared := map[string]bool{ + home: true, + filepath.Join(home, ".local"): true, + "/usr": true, + "/usr/local": true, + "/opt/homebrew": true, + } + // Never open a folder that holds the home folder, such as / or /Users. + containsHome := root == home || strings.HasPrefix(home, strings.TrimSuffix(root, "/")+"/") + if shared[root] || containsHome { + return bin + } + return root +} + +// realPath resolves the symlinks in path. A path that does not exist yet keeps +// its resolved parent, so a cache folder that a command creates later is +// still covered. +func realPath(path string) string { + if real, err := filepath.EvalSymlinks(path); err == nil { + return real + } + parent := filepath.Dir(path) + if parent == path { + return path + } + return filepath.Join(realPath(parent), filepath.Base(path)) +} + +func mapPaths(paths []string, f func(string) string) []string { + out := make([]string, len(paths)) + for i, p := range paths { + out[i] = f(p) + } + return out +} + +// sandboxCommand returns the program and arguments that run binary with args +// in the sandbox, and a function that removes the profile file. If the +// sandbox is not available, it returns binary and args unchanged. +func sandboxCommand(binary string, args []string, dir string, env []string) (string, []string, func(), error) { + noop := func() {} + if runtime.GOOS != "darwin" { + return binary, args, noop, nil + } + if _, err := os.Stat(sandboxExec); err != nil { + return binary, args, noop, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", nil, noop, fmt.Errorf("could not find the home folder for the agent sandbox: %w", err) + } + opencodeDir := "" + if path, err := exec.LookPath(binary); err == nil { + if real, err := filepath.EvalSymlinks(path); err == nil { + opencodeDir = installDir(home, real) + } + } + // Seatbelt matches the real path, so a folder behind a symlink, such as + // /var/folders on macOS, must be written as the folder it points to. + paths := buildSandboxPaths(realPath(home), realPath(dir), opencodeDir, env) + paths.ReadWrite = mapPaths(paths.ReadWrite, realPath) + paths.ReadOnly = mapPaths(paths.ReadOnly, realPath) + profile := seatbeltProfile(paths) + f, err := os.CreateTemp("", "ldcli-agent-*.sb") + if err != nil { + return "", nil, noop, err + } + if _, err := f.WriteString(profile); err != nil { + f.Close() + os.Remove(f.Name()) + return "", nil, noop, err + } + f.Close() + cleanup := func() { _ = os.Remove(f.Name()) } + return sandboxExec, append([]string{"-f", f.Name(), binary}, args...), cleanup, nil +} diff --git a/internal/setup/agent/sandbox_test.go b/internal/setup/agent/sandbox_test.go new file mode 100644 index 000000000..aa1eb19c8 --- /dev/null +++ b/internal/setup/agent/sandbox_test.go @@ -0,0 +1,108 @@ +package agent + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSeatbeltProfile_DeniesHomeThenOpensFolders(t *testing.T) { + profile := seatbeltProfile(sandboxPaths{ + Home: "/Users/me", + ReadWrite: []string{"/Users/me/app"}, + ReadOnly: []string{`/Users/me/odd "name"`}, + }) + + deny := strings.Index(profile, `(deny file-read* file-write* (subpath "/Users/me"))`) + allow := strings.Index(profile, `(allow file-read* file-write* (subpath "/Users/me/app"))`) + require.NotEqual(t, -1, deny) + require.NotEqual(t, -1, allow) + assert.Less(t, deny, allow, "later rules win, so the allows must follow the deny") + assert.Contains(t, profile, `(allow file-read* (subpath "/Users/me/odd \"name\""))`, "paths are quoted") +} + +func TestBuildSandboxPaths_OpensOnlyOpenCodesXDGFolders(t *testing.T) { + p := buildSandboxPaths("/Users/me", "/Users/me/app", "", []string{"XDG_CONFIG_HOME=/Users/me/.ldcli-local"}) + + assert.Contains(t, p.ReadWrite, "/Users/me/app") + assert.Contains(t, p.ReadWrite, "/Users/me/.local/share/opencode") + assert.Contains(t, p.ReadOnly, "/Users/me/.ldcli-local/opencode") + assert.NotContains(t, p.ReadOnly, "/Users/me/.ldcli-local", "the ldcli configuration next to it stays closed") + for _, path := range append(p.ReadOnly, p.ReadWrite...) { + assert.NotEqual(t, "/Users/me/.config", path) + assert.NotEqual(t, "/Users/me/.config/ldcli", path) + } +} + +func TestInstallDir(t *testing.T) { + assert.Equal(t, "/opt/homebrew/lib/node_modules/opencode-ai", installDir("/Users/me", "/opt/homebrew/lib/node_modules/opencode-ai/bin/opencode.exe")) + assert.Equal(t, "/Users/me/.opencode", installDir("/Users/me", "/Users/me/.opencode/bin/opencode")) + assert.Equal(t, "/Users/me/.local/bin", installDir("/Users/me", "/Users/me/.local/bin/opencode"), "a shared bin folder opens only itself") + assert.Equal(t, "/usr/local/bin", installDir("/Users/me", "/usr/local/bin/opencode")) + assert.Equal(t, "/bin", installDir("/Users/me", "/bin/opencode"), "never open / or another folder that holds the home folder") + assert.Equal(t, "/Users/bin", installDir("/Users/me", "/Users/bin/opencode")) +} + +func TestOpenCodeConfig_DeniesLDCLIAndSkillsInstalls(t *testing.T) { + data, err := openCodeConfig() + require.NoError(t, err) + + assert.Contains(t, string(data), `"ldcli*":"deny"`) + assert.Contains(t, string(data), `"npx skills*":"deny"`) +} + +// TestSandboxCommand_ConfinesReadsToTheProject runs a real sandboxed command on +// macOS, with a fake home folder. +func TestSandboxCommand_ConfinesReadsToTheProject(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("the sandbox is macOS only") + } + if _, err := os.Stat(sandboxExec); err != nil { + t.Skip("sandbox-exec is not available") + } + home := t.TempDir() + t.Setenv("HOME", home) + project := filepath.Join(home, "code", "app") + write := func(path, content string) string { + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + return path + } + inside := write(filepath.Join(project, "main.py"), "inside") + secrets := []string{ + write(filepath.Join(home, ".config", "ldcli", "config.yml"), "access-token: api-secret"), + write(filepath.Join(home, ".ssh", "id_ed25519"), "key"), + write(filepath.Join(home, "code", "other-repo", "main.py"), "other"), + } + + run := func(path string) (string, error) { + name, args, cleanup, err := sandboxCommand("/bin/cat", []string{path}, project, os.Environ()) + require.NoError(t, err) + defer cleanup() + require.Equal(t, sandboxExec, name) + out, err := exec.Command(name, args...).CombinedOutput() + return string(out), err + } + + out, err := run(inside) + require.NoError(t, err, out) + assert.Equal(t, "inside", out) + for _, path := range secrets { + out, err := run(path) + assert.Error(t, err, "read %s", path) + assert.Contains(t, out, "Operation not permitted", path) + } + + name, args, cleanup, err := sandboxCommand("/usr/bin/touch", []string{filepath.Join(project, "new.txt")}, project, os.Environ()) + require.NoError(t, err) + defer cleanup() + out2, err := exec.Command(name, args...).CombinedOutput() + require.NoError(t, err, string(out2)) + assert.FileExists(t, filepath.Join(project, "new.txt"), "the project stays writable") +} diff --git a/internal/setup/agent/server.go b/internal/setup/agent/server.go index 9424720a7..66efcba07 100644 --- a/internal/setup/agent/server.go +++ b/internal/setup/agent/server.go @@ -46,13 +46,19 @@ func startServer(ctx context.Context, binary, dir string, config []byte) (*serve return nil, err } - procCtx, cancel := context.WithCancel(context.Background()) - cmd := exec.CommandContext(procCtx, binary, "serve", "--hostname", "127.0.0.1", "--port", strconv.Itoa(port)) - cmd.Dir = dir - cmd.Env = childEnv(os.Environ(), map[string]string{ + env := childEnv(os.Environ(), map[string]string{ "OPENCODE_SERVER_PASSWORD": password, "OPENCODE_CONFIG_CONTENT": string(config), }) + name, args, cleanup, err := sandboxCommand(binary, []string{"serve", "--hostname", "127.0.0.1", "--port", strconv.Itoa(port)}, dir, env) + if err != nil { + return nil, err + } + + procCtx, cancel := context.WithCancel(context.Background()) + cmd := exec.CommandContext(procCtx, name, args...) + cmd.Dir = dir + cmd.Env = env // Ask OpenCode to stop so it can end its own child processes, and kill it if // it does not stop in time. cmd.Cancel = func() error { @@ -68,6 +74,7 @@ func startServer(ctx context.Context, binary, dir string, config []byte) (*serve if err := cmd.Start(); err != nil { cancel() + cleanup() return nil, fmt.Errorf("could not start OpenCode: %w", err) } s := &server{ @@ -83,6 +90,7 @@ func startServer(ctx context.Context, binary, dir string, config []byte) (*serve } go func() { _ = cmd.Wait() + cleanup() close(s.exited) }()