diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 00000000..d2f7a62e --- /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 55acc646..36b8485a 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 8e6787a2..9646d891 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/cmd/setup/agent.go b/cmd/setup/agent.go index 3faeec14..80b7cee1 100644 --- a/cmd/setup/agent.go +++ b/cmd/setup/agent.go @@ -9,7 +9,9 @@ import ( "os/signal" "path/filepath" "sort" + "strconv" "strings" + "syscall" "github.com/spf13/cobra" @@ -60,20 +62,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 +98,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 { @@ -106,7 +110,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), @@ -147,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 { @@ -176,12 +182,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 +225,15 @@ 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.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 @@ -231,6 +243,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 f0e56b7c..900ac4af 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() @@ -128,7 +141,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,13 +182,13 @@ 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) 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) } @@ -202,6 +215,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 +296,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 +314,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,6 +338,107 @@ 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 +// 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(), "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 d069b8af..54e29826 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 bc324cf1..1eeb3320 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" @@ -114,10 +117,17 @@ 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 + // 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 @@ -260,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. diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index 8fe4ae05..f79eb4f4 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -1,7 +1,9 @@ package setup import ( + "context" "fmt" + "time" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -51,14 +53,42 @@ 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: withConfigSource(runner, 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 = withConfigSource(runner, agentCmd.RunE) + cmd.AddCommand(agentCmd) return cmd } + +// withConfigSource gives the agent the setup-agent AI Config for the +// logged-in user, and closes it after the command runs, so the AI SDK sends +// its events before ldcli exits. The source is built when the command runs, +// because the access token and the base URI come from flags and the +// configuration file. +func withConfigSource(runner *agent.OpenCode, run func(*cobra.Command, []string) error) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + source := agent.NewAIConfigSource( + viper.GetString(cliflags.BaseURIFlag), + viper.GetString(cliflags.AccessTokenFlag), + ) + runner.Config = source + // 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) + defer cancel() + _ = closer.Close(ctx) + }() + } + return run(cmd, args) + } +} diff --git a/cmd/setup/update.go b/cmd/setup/update.go index 30ab082f..0bbc6719 100644 --- a/cmd/setup/update.go +++ b/cmd/setup/update.go @@ -51,11 +51,15 @@ 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 "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 @@ -273,7 +277,9 @@ 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.EventQuestion: + m.agentQuestions = append(m.agentQuestions, ev.Question) case agent.EventDone: m.agentDone = true m.closeAgent() @@ -296,7 +302,9 @@ 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 + 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 bb5e263d..691c5891 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 OpenCode 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") @@ -430,15 +430,37 @@ 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..." + 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 OpenCode..." + status = " Starting the agent..." } return body + m.spinner.View() + status + "\n\n" + mutedStyle.Render("q quit") } @@ -457,9 +479,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/go.mod b/go.mod index 67106fb3..d487fa30 100644 --- a/go.mod +++ b/go.mod @@ -1,16 +1,16 @@ module github.com/launchdarkly/ldcli -go 1.25 +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,14 +27,15 @@ 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-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 github.com/mattn/go-sqlite3 v1.14.52 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,13 @@ 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.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.40.0 + golang.org/x/term v0.46.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -54,45 +58,48 @@ 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 - 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 @@ -116,19 +123,29 @@ 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.41.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/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/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 8215df15..58438c46 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= @@ -189,20 +198,22 @@ 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-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= +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= @@ -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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +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,11 +443,15 @@ 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= 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= diff --git a/internal/setup/agent/agent.go b/internal/setup/agent/agent.go index 6a94c6a5..6fcaf103 100644 --- a/internal/setup/agent/agent.go +++ b/internal/setup/agent/agent.go @@ -32,6 +32,9 @@ type Config struct { ProviderID string ModelID string Instructions string + // Task is the first user message. If it is empty, the run sends the + // CLI's own task message from BuildPrompt. + Task string } // ConfigSource gives the agent configuration for a task. @@ -61,6 +64,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 +84,7 @@ type Event struct { Text string Tool ToolCall Permission PermissionRequest + Question QuestionRequest File string Usage Usage } @@ -106,6 +113,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 +162,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 cf027fec..76f5e46f 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" @@ -30,10 +31,28 @@ 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 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"}) @@ -50,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) { @@ -168,6 +193,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") @@ -246,6 +283,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) @@ -318,6 +362,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/aiconfig.go b/internal/setup/agent/aiconfig.go new file mode 100644 index 00000000..5977df83 --- /dev/null +++ b/internal/setup/agent/aiconfig.go @@ -0,0 +1,356 @@ +package agent + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "strings" + "sync" + "time" + + "github.com/launchdarkly/go-ai-sdk/aiserver" + "github.com/launchdarkly/go-sdk-common/v3/ldcontext" + ld "github.com/launchdarkly/go-server-sdk/v7" + "github.com/launchdarkly/go-server-sdk/v7/interfaces" + "github.com/launchdarkly/go-server-sdk/v7/ldcomponents" +) + +// SetupAgentConfigKey is the key of the setup-agent AI Config. +const SetupAgentConfigKey = "ldcli-setup-agent" + +const ( + contextKind = "ldcli-setup" + initTimeout = 5 * time.Second + // environmentIDEnv is the only setting that the AI SDK reads from the + // environment and that InitOptions cannot set. + environmentIDEnv = "LD_ENVIRONMENT_ID" + // RelayEnvironmentIDHeader is the response header in which the relay sends + // the client-side ID of the environment that it forwards to. The AI SDK + // sends that ID with its events and spans, so AgentControl can link runs + // to the config's Monitoring tab. The ID is not a secret, and the relay + // knows it because it holds that environment's SDK key. Like + // RelayTokenHeader, the name does not start with X-LD-. + RelayEnvironmentIDHeader = "LD-Setup-Agent-Environment-ID" +) + +// AIConfigSource reads the setup-agent AI Config with the LaunchDarkly AI SDK. +// It evaluates the config locally with the detected facts as context +// attributes, so targeting can give each kind of project its own +// instructions and model. +// +// The SDK reaches the config through the relay in gonfalon, which holds the +// real SDK key. The SDK gets a placeholder key, and a loopback proxy adds the +// user's access token to each request. So no SDK key is on the user's +// machine. +// +// If the SDK cannot start, for example because the relay refuses the request, +// or the config is missing or off, it returns the configuration of Fallback. +// Setup must not fail because the config is unavailable. +type AIConfigSource struct { + Key string + // RelayBaseURI is the user's base URI. The relay is at RelayPath under it. + RelayBaseURI string + AccessToken string + Fallback ConfigSource + + // checkRelay, initClient, and inspect are fields so that tests can replace + // them. + checkRelay func(ctx context.Context, pollingBase string) (string, error) + initClient func(ctx context.Context, opts aiserver.InitOptions) error + inspect func(ctx context.Context, key string, c ldcontext.Context) aiserver.InspectResult + + once sync.Once + initErr error + proxy *relayProxy + // started reports that this source started the SDK's global client, so + // Close must stop it. + started bool +} + +// NewAIConfigSource returns an AIConfigSource for a logged-in user, and +// DefaultConfigSource if there is no access token. +func NewAIConfigSource(baseURI, accessToken string) ConfigSource { + if strings.TrimSpace(accessToken) == "" || strings.TrimSpace(baseURI) == "" { + return DefaultConfigSource{} + } + return &AIConfigSource{ + Key: SetupAgentConfigKey, + RelayBaseURI: baseURI, + AccessToken: accessToken, + Fallback: DefaultConfigSource{}, + } +} + +// relayOptions sets every connection option of the AI SDK to the loopback +// proxy. The SDK fills an empty option from an environment variable, such as +// LD_BASE_URI or OTEL_EXPORTER_OTLP_ENDPOINT. ldcli uses LD_BASE_URI for its own +// API address, so an empty option would connect the SDK to the wrong place. +// +// The paths under the proxy match the relay routes: the SDK adds its own +// paths, such as /all for the stream and /v1/traces for spans. +func relayOptions(proxyBase string) aiserver.InitOptions { + return aiserver.InitOptions{ + SDKKey: RelayPlaceholderKey, + BaseURI: proxyBase + "/polling", + StreamURI: proxyBase + "/stream", + EventsURI: proxyBase + "/events", + OTLPEndpoint: proxyBase + "/otel", + ServiceName: "ldcli", + Environment: "ldcli-setup", + WaitFor: initTimeout, + } +} + +func (s *AIConfigSource) start(ctx context.Context) error { + proxy, err := startRelayProxy(s.RelayBaseURI, s.AccessToken) + if err != nil { + return err + } + s.proxy = proxy + opts := relayOptions(proxy.baseURL) + check := s.checkRelay + if check == nil { + check = checkRelay + } + environmentID, err := check(ctx, opts.BaseURI) + if err != nil { + return err + } + setEnvironmentID(environmentID) + if s.initClient != nil { + return s.initClient(ctx, opts) + } + if err := initQuietClient(ctx, opts); err != nil { + return err + } + s.started = true + return nil +} + +// setEnvironmentID sets LD_ENVIRONMENT_ID for this process, and clears it when +// the relay sent no ID, so a value in the user's shell for their own app does +// not reach the SDK. OpenCode does not get the variable, because the CLI +// removes LD_ variables from OpenCode's environment. +func setEnvironmentID(id string) { + if id == "" { + _ = os.Unsetenv(environmentIDEnv) + return + } + _ = os.Setenv(environmentIDEnv, id) +} + +// initQuietClient starts the AI SDK with a LaunchDarkly client that does not +// log. The SDK's own client logs to the terminal, which breaks the wizard's +// full-screen view, and InitOptions cannot turn its logs off. +// +// The client polls and does not open the flag stream. Setup reads the config +// once per run, so one polling request is all it needs, and it does not +// depend on a long-lived connection through the relay and the load balancer. +func initQuietClient(ctx context.Context, opts aiserver.InitOptions) error { + aiserver.SetLogger(slog.New(slog.NewTextHandler(io.Discard, nil))) + client, err := ld.MakeCustomClient(opts.SDKKey, ld.Config{ + ServiceEndpoints: interfaces.ServiceEndpoints{ + Polling: opts.BaseURI, + Streaming: opts.StreamURI, + Events: opts.EventsURI, + }, + DataSource: ldcomponents.PollingDataSource(), + Logging: ldcomponents.NoLogging(), + DiagnosticOptOut: true, + }, opts.WaitFor) + if err != nil { + if client != nil { + _ = client.Close() + } + return err + } + if _, err := aiserver.InitWithClient(ctx, client, opts); err != nil { + _ = client.Close() + return err + } + return nil +} + +// 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) + } + }) + 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) + } + + inspect := s.inspect + if inspect == nil { + inspect = aiserver.InspectConfig + } + res := inspect(ctx, s.key(), setupContext(task)) + if !res.Enabled || res.Config == nil { + return s.fallback(ctx, task) + } + return configFromAI(res.Config, task), nil +} + +func (s *AIConfigSource) fallback(ctx context.Context, task Task) (Config, error) { + if s.Fallback == nil { + return DefaultConfigSource{}.Resolve(ctx, task) + } + return s.Fallback.Resolve(ctx, task) +} + +// Close flushes the SDK events through the relay, stops the SDK client, and +// then stops the proxy. +func (s *AIConfigSource) Close(ctx context.Context) error { + var err error + if s.started { + err = aiserver.Shutdown(ctx) + s.started = false + } + s.closeProxy(ctx) + return err +} + +func (s *AIConfigSource) closeProxy(ctx context.Context) { + if s.proxy != nil { + _ = s.proxy.close(ctx) + s.proxy = nil + } +} + +// setupContext is the evaluation context for one run. Its attributes are the +// detected facts that targeting rules can match. It has no file paths and no +// names from the project, because the context goes to LaunchDarkly in +// analytics events. +func setupContext(t Task) ldcontext.Context { + key, err := randomHex(16) + if err != nil { + key = "ldcli-setup" + } + return ldcontext.NewBuilder(key). + Kind(contextKind). + Anonymous(true). + SetString("sdkId", t.SDKID). + SetString("language", t.Language). + SetString("framework", t.Framework). + SetString("packageManager", t.PackageManager). + SetBool("hasEntryPoint", t.EntryPoint != ""). + SetBool("sdkInstalled", t.SDKInstalled). + Build() +} + +// openCodeProviders maps AI Config provider names to OpenCode provider IDs +// where the two are different. +var openCodeProviders = map[string]string{ + "bedrock": "amazon-bedrock", + "gemini": "google", +} + +// configFromAI turns an AI Config variation into an agent configuration. An +// agent-mode config has instructions. A completion-mode config has messages, +// and its system messages become the instructions. +// +// The config's instructions decide the task. The first user message gives +// only the facts from setup. If the variation has no instructions, the +// built-in instructions and the CLI's own task message apply. +func configFromAI(c *aiserver.AiConfig, task Task) Config { + cfg := Config{Instructions: c.Instructions, ModelID: c.Model.Name} + if cfg.Instructions == "" { + var system []string + for _, m := range c.Messages { + if m.Role == "system" && m.Text != "" { + system = append(system, m.Text) + } + } + cfg.Instructions = strings.Join(system, "\n\n") + } + if cfg.Instructions == "" { + cfg.Instructions = DefaultInstructions + } else { + cfg.Task = BuildFacts(task) + } + 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 + } + modelID = c.Model.Name + if providerID == "" || modelID == "" { + return "", "" + } + return providerID, modelID +} + +// RelayError is a refusal from the relay, for example because the kill switch +// is off or the access token is not valid. +type RelayError struct { + Status int + Code string + Message string +} + +func (e *RelayError) Error() string { + if e.Message != "" { + return fmt.Sprintf("the setup agent service refused the request (%s): %s", e.Code, e.Message) + } + if e.Code != "" { + return fmt.Sprintf("the setup agent service refused the request (%s)", e.Code) + } + return fmt.Sprintf("the setup agent service returned HTTP %d", e.Status) +} + +// checkRelay makes one polling request through the proxy before the SDK +// starts. The Go server SDK retries a refused stream until its start timeout +// ends, so without this check a refusal would delay every setup run. It +// returns the environment ID that the relay sends, or an empty string. +func checkRelay(ctx context.Context, pollingBase string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, initTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, pollingBase+"/sdk/latest-all", nil) + if err != nil { + return "", err + } + req.Header.Set("Authorization", RelayPlaceholderKey) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("could not reach the setup agent service: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 300 { + return strings.TrimSpace(resp.Header.Get(RelayEnvironmentIDHeader)), nil + } + refusal := &RelayError{Status: resp.StatusCode} + var body struct { + Code string `json:"code"` + Message string `json:"message"` + } + if json.NewDecoder(io.LimitReader(resp.Body, 4096)).Decode(&body) == nil { + refusal.Code, refusal.Message = body.Code, body.Message + } + return "", refusal +} diff --git a/internal/setup/agent/aiconfig_test.go b/internal/setup/agent/aiconfig_test.go new file mode 100644 index 00000000..d6c56a69 --- /dev/null +++ b/internal/setup/agent/aiconfig_test.go @@ -0,0 +1,202 @@ +package agent + +import ( + "context" + "errors" + "os" + "strings" + "testing" + + "github.com/launchdarkly/go-ai-sdk/aiserver" + "github.com/launchdarkly/go-sdk-common/v3/ldcontext" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRelayOptions_SetsEveryConnectionOption(t *testing.T) { + opts := relayOptions("http://127.0.0.1:5000/secret") + + assert.Equal(t, aiserver.InitOptions{ + SDKKey: RelayPlaceholderKey, + BaseURI: "http://127.0.0.1:5000/secret/polling", + StreamURI: "http://127.0.0.1:5000/secret/stream", + EventsURI: "http://127.0.0.1:5000/secret/events", + OTLPEndpoint: "http://127.0.0.1:5000/secret/otel", + ServiceName: "ldcli", + Environment: "ldcli-setup", + WaitFor: initTimeout, + }, opts, "an empty option would make the SDK read an LD_ variable") +} + +func TestNewAIConfigSource(t *testing.T) { + assert.Equal(t, DefaultConfigSource{}, NewAIConfigSource("https://app.launchdarkly.com", ""), "no login, no AI Config") + + source, ok := NewAIConfigSource("https://app.launchdarkly.com", "api-123").(*AIConfigSource) + require.True(t, ok) + assert.Equal(t, SetupAgentConfigKey, source.Key) + assert.Equal(t, "https://app.launchdarkly.com", source.RelayBaseURI) + assert.Equal(t, "api-123", source.AccessToken) +} + +func TestAIConfigSource_StartsTheSDKBehindTheProxy(t *testing.T) { + t.Setenv(environmentIDEnv, "the-users-own-app") + var got aiserver.InitOptions + s := &AIConfigSource{ + RelayBaseURI: "https://app.launchdarkly.com", + AccessToken: "api-123", + checkRelay: func(context.Context, string) (string, error) { return "catfood-client-id", nil }, + initClient: func(_ context.Context, opts aiserver.InitOptions) error { + got = opts + return nil + }, + inspect: func(context.Context, string, ldcontext.Context) aiserver.InspectResult { + return aiserver.InspectResult{} + }, + } + defer s.Close(context.Background()) + + _, err := s.Resolve(context.Background(), Task{}) + + require.NoError(t, err) + assert.Equal(t, RelayPlaceholderKey, got.SDKKey) + assert.True(t, strings.HasPrefix(got.StreamURI, "http://127.0.0.1:"), got.StreamURI) + assert.NotContains(t, got.StreamURI, "api-123", "the token is never in an SDK option") + assert.Equal(t, "catfood-client-id", os.Getenv(environmentIDEnv), "the ID comes from the relay") +} + +func TestAIConfigSource_NoEnvironmentID_ClearsTheUsersValue(t *testing.T) { + t.Setenv(environmentIDEnv, "the-users-own-app") + s, _ := fakeSource(nil, aiserver.InspectResult{}) + defer s.Close(context.Background()) + + _, _ = s.Resolve(context.Background(), Task{}) + + _, set := os.LookupEnv(environmentIDEnv) + assert.False(t, set) +} + +type inspectCall struct { + key string + ctx ldcontext.Context +} + +func fakeSource(initErr error, res aiserver.InspectResult) (*AIConfigSource, *[]inspectCall) { + var calls []inspectCall + s := &AIConfigSource{ + Key: "setup", + RelayBaseURI: "https://app.launchdarkly.com", + AccessToken: "api-123", + checkRelay: noRelayCheck, + initClient: func(context.Context, aiserver.InitOptions) error { return initErr }, + inspect: func(_ context.Context, key string, c ldcontext.Context) aiserver.InspectResult { + calls = append(calls, inspectCall{key: key, ctx: c}) + return res + }, + } + return s, &calls +} + +func enabled(c aiserver.AiConfig) aiserver.InspectResult { + return aiserver.InspectResult{Enabled: true, Config: &c} +} + +func agentConfig(provider, model, instructions string) aiserver.AiConfig { + c := aiserver.AiConfig{Instructions: instructions, Model: aiserver.Model{Name: model}} + c.Provider.Name = provider + return c +} + +func TestAIConfigSource_AgentMode(t *testing.T) { + s, calls := fakeSource(nil, enabled(agentConfig("anthropic", "claude-sonnet-5", "Edit the entry point."))) + + cfg, err := s.Resolve(context.Background(), Task{SDKID: "node-server", Language: "JavaScript", Framework: "nextjs", EntryPoint: "app/layout.tsx", Dir: "/home/me/secret-project"}) + + require.NoError(t, err) + assert.Equal(t, "anthropic", cfg.ProviderID) + assert.Equal(t, "claude-sonnet-5", cfg.ModelID) + assert.Equal(t, "Edit the entry point.", cfg.Instructions, "the config's instructions are the system prompt") + assert.Contains(t, cfg.Task, "Follow your instructions", "the first message gives facts, not a task") + assert.Contains(t, cfg.Task, "- SDK: node-server") + assert.NotContains(t, cfg.Task, "Evaluate the boolean flag", "the CLI's own task is not sent") + require.Len(t, *calls, 1) + call := (*calls)[0] + assert.Equal(t, "setup", call.key) + assert.Equal(t, ldcontext.Kind("ldcli-setup"), call.ctx.Kind()) + assert.True(t, call.ctx.Anonymous()) + assert.Equal(t, "nextjs", call.ctx.GetValue("framework").StringValue()) + assert.Equal(t, "node-server", call.ctx.GetValue("sdkId").StringValue()) + assert.True(t, call.ctx.GetValue("hasEntryPoint").BoolValue()) + for _, name := range call.ctx.GetOptionalAttributeNames(nil) { + value := call.ctx.GetValue(name).StringValue() + assert.NotContains(t, value, "secret-project", "the context must not carry paths") + assert.NotContains(t, value, "layout.tsx", "the context must not carry paths") + } +} + +func TestAIConfigSource_CompletionModeUsesSystemMessages(t *testing.T) { + c := agentConfig("openai", "gpt-6", "") + c.Messages = []aiserver.Message{{Role: "system", Text: "One"}, {Role: "user", Text: "skip"}, {Role: "system", Text: "Two"}} + s, _ := fakeSource(nil, enabled(c)) + + cfg, err := s.Resolve(context.Background(), Task{}) + + require.NoError(t, err) + assert.Equal(t, "One\n\nTwo", cfg.Instructions) +} + +func TestAIConfigSource_MapsProviderNames(t *testing.T) { + s, _ := fakeSource(nil, enabled(agentConfig("Bedrock", "anthropic.claude", "x"))) + + cfg, err := s.Resolve(context.Background(), Task{}) + + require.NoError(t, err) + assert.Equal(t, "amazon-bedrock", cfg.ProviderID) +} + +func TestAIConfigSource_ModelWithoutProvider_UsesOpenCodeDefault(t *testing.T) { + s, _ := fakeSource(nil, enabled(agentConfig("", "claude-sonnet-5", ""))) + + cfg, err := s.Resolve(context.Background(), Task{}) + + require.NoError(t, err) + assert.Equal(t, Config{Instructions: DefaultInstructions}, cfg, "no instructions: the built-in task applies") +} + +func TestAIConfigSource_FallsBack(t *testing.T) { + for name, s := range map[string]*AIConfigSource{ + "init fails": first(fakeSource(errors.New("no network"), aiserver.InspectResult{})), + "config is off": first(fakeSource(nil, aiserver.InspectResult{Enabled: false})), + "config missing": first(fakeSource(nil, aiserver.InspectResult{Enabled: true})), + } { + t.Run(name, func(t *testing.T) { + cfg, err := s.Resolve(context.Background(), Task{}) + + require.NoError(t, err) + assert.Equal(t, Config{Instructions: DefaultInstructions}, cfg) + }) + } +} + +func TestAIConfigSource_InitsOnce(t *testing.T) { + inits := 0 + s := &AIConfigSource{ + RelayBaseURI: "https://app.launchdarkly.com", + checkRelay: noRelayCheck, + initClient: func(context.Context, aiserver.InitOptions) error { inits++; return nil }, + inspect: func(context.Context, string, ldcontext.Context) aiserver.InspectResult { + return aiserver.InspectResult{} + }, + } + + _, _ = s.Resolve(context.Background(), Task{}) + _, _ = s.Resolve(context.Background(), Task{}) + + assert.Equal(t, 1, inits) + assert.NoError(t, s.Close(context.Background()), "a source that did not start the SDK has nothing to stop") +} + +func first(s *AIConfigSource, _ *[]inspectCall) *AIConfigSource { return s } + +// noRelayCheck stands in for the request to the relay, so unit tests make no +// network calls. +func noRelayCheck(context.Context, string) (string, error) { return "", nil } diff --git a/internal/setup/agent/client.go b/internal/setup/agent/client.go index 42da5619..82f68aa9 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 8dccd847..fe3ad486 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 ed6616de..8c90baa5 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{} @@ -86,22 +95,110 @@ 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 } - 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. @@ -112,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) @@ -139,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) { @@ -186,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}) } @@ -203,10 +347,26 @@ 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() { + // 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_config.go b/internal/setup/agent/opencode_config.go index c612473e..70d34885 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,48 @@ 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"}, + // 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"}, + {"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. diff --git a/internal/setup/agent/opencode_handler.go b/internal/setup/agent/opencode_handler.go new file mode 100644 index 00000000..3b8bd0b8 --- /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 00000000..5df1dd5b --- /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") +} diff --git a/internal/setup/agent/prompt.go b/internal/setup/agent/prompt.go index ed5b90cf..ba39f947 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. @@ -27,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 @@ -45,8 +87,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") } diff --git a/internal/setup/agent/relay_proxy.go b/internal/setup/agent/relay_proxy.go new file mode 100644 index 00000000..433ece89 --- /dev/null +++ b/internal/setup/agent/relay_proxy.go @@ -0,0 +1,107 @@ +package agent + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "net/http/httputil" + "net/url" + "strings" + "time" +) + +const ( + // RelayPath is the path of the SDK relay in gonfalon, under the user's base + // URI. + RelayPath = "/internal/ldcli-setup-agent/sdk" + // RelayTokenHeader carries the user's access token to the relay. The relay + // cannot use Authorization, because the SDK puts its key there. The name + // must not start with X-LD-: gonfalon removes client request headers with + // that prefix, because its private auth headers use it. + RelayTokenHeader = "LD-CLI-Token" + // RelayPlaceholderKey is the SDK key that the CLI gives the AI SDK. The + // relay replaces it with the real key on the way to catfood. + RelayPlaceholderKey = "ldcli-placeholder" +) + +// relayProxy is a loopback HTTP server between the AI SDK and the relay. It +// adds the access token to each request, so the token is never in an SDK +// option, an environment variable, or the OpenCode process. The AI SDK cannot +// add a header to its trace exports, which is the other reason for the proxy. +// +// The proxy accepts requests only under a random path, so another process on +// the machine, such as OpenCode, cannot use it to call the relay with the +// user's token. +type relayProxy struct { + server *http.Server + listener net.Listener + // baseURL is the proxy address with the random path, for example + // http://127.0.0.1:52011/3f9a.... + baseURL string +} + +// startRelayProxy starts the proxy. relayBase is the user's base URI, for +// example https://app.launchdarkly.com. +func startRelayProxy(relayBase, accessToken string) (*relayProxy, error) { + target, err := url.Parse(strings.TrimRight(relayBase, "/") + RelayPath) + if err != nil || target.Scheme == "" || target.Host == "" { + return nil, fmt.Errorf("the base URI %q is not a valid URL", relayBase) + } + secret, err := randomHex(16) + if err != nil { + return nil, err + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, fmt.Errorf("could not start the relay proxy: %w", err) + } + + prefix := "/" + secret + proxy := &httputil.ReverseProxy{ + Rewrite: func(r *httputil.ProxyRequest) { + rest := strings.TrimPrefix(r.In.URL.Path, prefix) + r.Out.URL.Scheme = target.Scheme + r.Out.URL.Host = target.Host + r.Out.URL.Path = target.Path + rest + r.Out.URL.RawPath = "" + r.Out.Host = target.Host + r.Out.Header.Set(RelayTokenHeader, accessToken) + }, + // Send each part of a response as it arrives. The flag stream is a + // long-lived event stream, and a buffer would hold its events back. + FlushInterval: -1, + } + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != prefix && !strings.HasPrefix(r.URL.Path, prefix+"/") { + http.NotFound(w, r) + return + } + proxy.ServeHTTP(w, r) + }) + + p := &relayProxy{ + server: &http.Server{ + Handler: handler, + ReadHeaderTimeout: 10 * time.Second, + }, + listener: listener, + baseURL: "http://" + listener.Addr().String() + prefix, + } + go func() { + if err := p.server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { + _ = listener.Close() + } + }() + return p, nil +} + +// close stops the proxy and ends open connections, including the flag +// stream. +func (p *relayProxy) close(ctx context.Context) error { + if err := p.server.Shutdown(ctx); err != nil { + return p.server.Close() + } + return nil +} diff --git a/internal/setup/agent/relay_proxy_test.go b/internal/setup/agent/relay_proxy_test.go new file mode 100644 index 00000000..11bbc0ae --- /dev/null +++ b/internal/setup/agent/relay_proxy_test.go @@ -0,0 +1,100 @@ +package agent + +import ( + "bufio" + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeRelay records the last request and answers like the relay. +func fakeRelay(t *testing.T) (*httptest.Server, *http.Request) { + t.Helper() + got := &http.Request{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + *got = *r.Clone(context.Background()) + if strings.HasSuffix(r.URL.Path, "/stream/all") { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, "event: put\ndata: {}\n\n") + w.(http.Flusher).Flush() + <-r.Context().Done() + return + } + _, _ = io.WriteString(w, `{"flags":{}}`) + })) + t.Cleanup(srv.Close) + return srv, got +} + +func startTestProxy(t *testing.T, relayBase string) *relayProxy { + t.Helper() + p, err := startRelayProxy(relayBase, "api-secret-token") + require.NoError(t, err) + t.Cleanup(func() { _ = p.close(context.Background()) }) + return p +} + +func TestRelayProxy_AddsTheTokenAndMapsThePath(t *testing.T) { + relay, got := fakeRelay(t) + p := startTestProxy(t, relay.URL+"/") + + req, err := http.NewRequest(http.MethodGet, p.baseURL+"/polling/sdk/latest-all?basis=1", nil) + require.NoError(t, err) + req.Header.Set("Authorization", RelayPlaceholderKey) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, `{"flags":{}}`, string(body)) + assert.Equal(t, RelayPath+"/polling/sdk/latest-all", got.URL.Path) + assert.Equal(t, "basis=1", got.URL.RawQuery) + assert.Equal(t, "api-secret-token", got.Header.Get(RelayTokenHeader)) + assert.Equal(t, RelayPlaceholderKey, got.Header.Get("Authorization")) +} + +func TestRelayProxy_RefusesRequestsWithoutTheSecretPath(t *testing.T) { + relay, got := fakeRelay(t) + p := startTestProxy(t, relay.URL) + host := strings.TrimSuffix(p.baseURL, p.baseURL[strings.LastIndex(p.baseURL, "/"):]) + + resp, err := http.Get(host + "/polling/sdk/latest-all") + require.NoError(t, err) + resp.Body.Close() + + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + assert.Empty(t, got.Header.Get(RelayTokenHeader), "a request without the secret never reaches the relay") +} + +func TestRelayProxy_StreamsWithoutBuffering(t *testing.T) { + relay, _ := fakeRelay(t) + p := startTestProxy(t, relay.URL) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.baseURL+"/stream/all", nil) + require.NoError(t, err) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + // The relay holds the stream open, so this line arrives only if the proxy + // sends it on at once. + line, err := bufio.NewReader(resp.Body).ReadString('\n') + require.NoError(t, err) + assert.Equal(t, "event: put\n", line) +} + +func TestStartRelayProxy_RejectsAnInvalidBaseURI(t *testing.T) { + _, err := startRelayProxy("not a url", "token") + + assert.Error(t, err) +} diff --git a/internal/setup/agent/relay_sdk_test.go b/internal/setup/agent/relay_sdk_test.go new file mode 100644 index 00000000..f8ebd8cd --- /dev/null +++ b/internal/setup/agent/relay_sdk_test.go @@ -0,0 +1,114 @@ +package agent + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// setupAgentFlag is the flag behind an agent-mode AI Config, as the stream +// sends it. +const setupAgentFlag = `{"key":"ldcli-setup-agent","on":true,"version":1,"salt":"s", +"fallthrough":{"variation":0},"offVariation":0, +"variations":[{"_ldMeta":{"enabled":true,"variationKey":"v1","mode":"agent","version":1}, +"model":{"name":"claude-sonnet-5"},"provider":{"name":"anthropic"}, +"instructions":"From AgentControl."}]}` + +// TestAIConfigSource_RealSDKThroughTheProxy starts the real AI SDK through the +// loopback proxy against a fake relay. It checks that the SDK uses the relay +// paths, sends the placeholder key and the token, and reads the config. +func TestAIConfigSource_RealSDKThroughTheProxy(t *testing.T) { + var mu sync.Mutex + var paths []string + relay := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + paths = append(paths, r.Method+" "+r.URL.Path) + mu.Unlock() + if r.Header.Get(RelayTokenHeader) != "api-123" || r.Header.Get("Authorization") != RelayPlaceholderKey { + w.WriteHeader(http.StatusUnauthorized) + return + } + if r.URL.Path == RelayPath+"/polling/sdk/latest-all" { + w.Header().Set("Content-Type", "application/json") + w.Header().Set(RelayEnvironmentIDHeader, "catfood-client-id") + fmt.Fprintf(w, `{"flags":{"ldcli-setup-agent":%s},"segments":{}}`, strings.ReplaceAll(setupAgentFlag, "\n", "")) + return + } + _, _ = io.Copy(io.Discard, r.Body) + w.WriteHeader(http.StatusAccepted) + })) + defer relay.Close() + t.Setenv(environmentIDEnv, "the-users-own-app") + + source := NewAIConfigSource(relay.URL, "api-123") + closer := source.(*AIConfigSource) + defer func() { _ = closer.Close(context.Background()) }() + + cfg, err := source.Resolve(context.Background(), Task{SDKID: "node-server"}) + + require.NoError(t, err) + assert.Equal(t, "anthropic/claude-sonnet-5", cfg.ProviderID+"/"+cfg.ModelID) + assert.Equal(t, "From AgentControl.", cfg.Instructions) + assert.Equal(t, "catfood-client-id", os.Getenv(environmentIDEnv), "the ID from the relay header reaches the SDK") + require.NoError(t, closer.Close(context.Background())) + mu.Lock() + defer mu.Unlock() + for _, p := range paths { + assert.Contains(t, p, RelayPath+"/", "every request goes to a relay route") + } + joined := strings.Join(paths, "\n") + assert.Contains(t, joined, "GET "+RelayPath+"/polling/sdk/latest-all", "the SDK polls") + assert.NotContains(t, joined, RelayPath+"/stream/", "the SDK does not open the flag stream") + t.Logf("requests: %v", paths) +} + +// TestAIConfigSource_RelayRefuses_FallsBack checks that a refusal, such as the +// kill switch being off, ends setup's wait at once and uses the built-in +// configuration. +func TestAIConfigSource_RelayRefuses_FallsBack(t *testing.T) { + relay := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = io.WriteString(w, `{"code":"ldcli_setup_agent_disabled"}`) + })) + defer relay.Close() + + source := NewAIConfigSource(relay.URL, "api-123").(*AIConfigSource) + defer func() { _ = source.Close(context.Background()) }() + + start := time.Now() + cfg, err := source.Resolve(context.Background(), Task{}) + + require.NoError(t, err) + assert.Equal(t, Config{Instructions: DefaultInstructions}, cfg) + assert.Less(t, time.Since(start), time.Second, "a refusal does not wait for the SDK start timeout") + var refusal *RelayError + require.ErrorAs(t, source.initErr, &refusal) + assert.Equal(t, "ldcli_setup_agent_disabled", refusal.Code) +} + +func TestAIConfigSource_RelayUnreachable_FallsBack(t *testing.T) { + relay := httptest.NewServer(http.NotFoundHandler()) + url := relay.URL + relay.Close() + + source := NewAIConfigSource(url, "api-123").(*AIConfigSource) + defer func() { _ = source.Close(context.Background()) }() + + start := time.Now() + cfg, err := source.Resolve(context.Background(), Task{}) + + require.NoError(t, err) + assert.Equal(t, Config{Instructions: DefaultInstructions}, cfg) + assert.Less(t, time.Since(start), time.Second) +} diff --git a/internal/setup/agent/sandbox.go b/internal/setup/agent/sandbox.go new file mode 100644 index 00000000..58a4af58 --- /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 00000000..aa1eb19c --- /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 9424720a..66efcba0 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) }()