diff --git a/cmd/setup/agent.go b/cmd/setup/agent.go new file mode 100644 index 000000000..80b7cee1a --- /dev/null +++ b/cmd/setup/agent.go @@ -0,0 +1,287 @@ +package setup + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "os/signal" + "path/filepath" + "sort" + "strconv" + "strings" + "syscall" + + "github.com/spf13/cobra" + + "github.com/launchdarkly/ldcli/internal/errors" + "github.com/launchdarkly/ldcli/internal/setup" + "github.com/launchdarkly/ldcli/internal/setup/agent" +) + +// agentMode is the value of the --agent flag. +type agentMode string + +const ( + // agentOff always uses the init templates. It is the zero value, so a wizard + // built without a mode never starts OpenCode. + agentOff agentMode = "" + // agentAuto uses the agent if it can run, and the init templates if not. + agentAuto agentMode = "auto" + // agentOn uses the agent, and stops with an error if it cannot run. + agentOn agentMode = "on" +) + +const agentFlag = "agent" + +func parseAgentMode(s string) (agentMode, error) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "auto": + return agentAuto, nil + case "on": + return agentOn, nil + case "off": + return agentOff, nil + } + return agentOff, errors.NewError(fmt.Sprintf("--agent must be auto, on, or off, not %q", s)) +} + +// agentTask builds the agent task from the wizard's detection result. +func agentTask(dir string, detection *setup.DetectResult, flagKey string, sdkInstalled bool) agent.Task { + t := agent.Task{Dir: dir, FlagKey: flagKey, SDKInstalled: sdkInstalled} + if detection != nil { + t.SDKID = detection.SDKID + t.Language = detection.Language + t.Framework = detection.Framework + t.PackageManager = detection.PackageManager + if detection.EntryPointExists { + t.EntryPoint = detection.EntryPoint + } + } + return t +} + +// 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 "" + } + return fmt.Sprintf("export %s=%s", name, value) +} + +// describeTool returns one line for a tool call. +func describeTool(tc agent.ToolCall) string { + what := tc.Name + if tc.Title != "" { + what = tc.Name + ": " + tc.Title + } + switch tc.Status { + case "completed": + return "✓ " + what + case "error": + return "✗ " + what + } + return "… " + what +} + +// describePermission returns the question for a permission request. +func describePermission(req agent.PermissionRequest) string { + if len(req.Patterns) == 0 { + return fmt.Sprintf("The agent asks to use %s.", req.Permission) + } + 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 { + cmd := &cobra.Command{ + Use: "agent", + Short: "Use the setup agent to add SDK initialization code to a project", + Long: `Runs only the agent step of setup. + +The setup agent uses OpenCode to edit the project. OpenCode must be installed. +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), + } + + cmd.Flags().String(pathFlag, ".", "Path to the project directory") + cmd.Flags().String(sdkIDFlag, "", "SDK identifier (e.g. node-server, react-client-sdk)") + _ = cmd.MarkFlagRequired(sdkIDFlag) + cmd.Flags().String(flagKeyFlag, "", "Feature flag key that the new code evaluates") + _ = cmd.MarkFlagRequired(flagKeyFlag) + cmd.Flags().String(sdkKeyFlag, "", "Server-side SDK key, shown in the closing instructions only") + cmd.Flags().String(clientIDFlag, "", "Client-side environment ID, shown in the closing instructions only") + cmd.Flags().String(mobileFlag, "", "Mobile SDK key, shown in the closing instructions only") + + return cmd +} + +func runAgent(svc setup.Service, runner agent.Runner) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + dir, err := filepath.Abs(getFlag(cmd, pathFlag)) + if err != nil { + return err + } + sdkID := getFlag(cmd, sdkIDFlag) + + // Reuse detection for the facts in the prompt. If the project is a + // different language from the SDK, the detected entry point is wrong, so + // look it up again for the SDK that the user named. + detection, err := svc.Detect(dir) + if err != nil || detection == nil { + detection = &setup.DetectResult{} + } + if detection.SDKID != sdkID { + detection.SDKID = sdkID + detection.Framework = "" + detection.EntryPoint, detection.EntryPointExists = setup.EntryPointFor(dir, sdkID) + detection.PackageManager = setup.PackageManagerFor(dir, sdkID) + } + task := agentTask(dir, detection, getFlag(cmd, flagKeyFlag), setup.IsInstalled(dir, sdkID)) + + // 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 { + return err + } + out := cmd.OutOrStdout() + fmt.Fprintf(out, "Starting the setup agent in %s\n", dir) + run, err := runner.Start(ctx, task) + if err != nil { + return err + } + defer run.Close() + + files, err := streamAgent(ctx, run, out, bufio.NewReader(cmd.InOrStdin())) + if err != nil { + return err + } + + fmt.Fprintln(out) + fmt.Fprintln(out) + if len(files) == 0 { + fmt.Fprintln(out, "The setup agent finished but did not report changed files.") + } else { + fmt.Fprintln(out, "The setup agent changed these files:") + for _, f := range files { + fmt.Fprintf(out, " %s\n", f) + } + } + 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:") + fmt.Fprintf(out, " %s\n", export) + } + return nil + } +} + +// streamAgent prints the run's events and asks the user to answer permission +// requests. It returns the changed files when the run finishes. +func streamAgent(ctx context.Context, run agent.Run, out io.Writer, in *bufio.Reader) ([]string, error) { + changed := map[string]bool{} + for { + var ev agent.Event + var ok bool + select { + case <-ctx.Done(): + return nil, ctx.Err() + case ev, ok = <-run.Events(): + } + if !ok { + return nil, errors.NewError("The setup agent stopped without a result.") + } + switch ev.Kind { + case agent.EventText: + fmt.Fprint(out, ev.Text) + case agent.EventTool: + if ev.Tool.Status != "running" { + fmt.Fprintf(out, "\n%s\n", describeTool(ev.Tool)) + } + case agent.EventFileEdited: + changed[ev.File] = true + case agent.EventUsage: + if ev.Usage.ModelID != "" { + fmt.Fprintf(out, "\n(model %s/%s, %d input and %d output tokens)\n", + ev.Usage.ProviderID, ev.Usage.ModelID, ev.Usage.InputTokens, ev.Usage.OutputTokens) + } + case agent.EventPermission: + fmt.Fprintf(out, "\n%s\nAllow? [y/N] ", describePermission(ev.Permission)) + 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 the agent: %w", err) + } + case agent.EventQuestion: + answers, err := askQuestions(out, in, ev.Question) + if err != nil { + return nil, err + } + if err := run.Answer(ctx, ev.Question, answers); err != nil { + return nil, fmt.Errorf("could not answer the agent: %w", err) + } + case agent.EventDone: + return sortedKeys(changed), nil + case agent.EventError: + return nil, errors.NewError("The setup agent failed: " + ev.Text) + } + } +} + +// 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 { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/cmd/setup/agent_test.go b/cmd/setup/agent_test.go new file mode 100644 index 000000000..900ac4af7 --- /dev/null +++ b/cmd/setup/agent_test.go @@ -0,0 +1,444 @@ +package setup + +import ( + "bufio" + "bytes" + "context" + "strings" + "sync" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/launchdarkly/ldcli/internal/setup" + "github.com/launchdarkly/ldcli/internal/setup/agent" +) + +type fakeReply struct { + id string + 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 +} + +func newFakeRun(events ...agent.Event) *fakeRun { + r := &fakeRun{events: make(chan agent.Event, len(events)+1)} + for _, e := range events { + r.events <- e + } + return r +} + +func (r *fakeRun) Events() <-chan agent.Event { return r.events } + +func (r *fakeRun) Reply(_ context.Context, req agent.PermissionRequest, allow bool) error { + r.mu.Lock() + defer r.mu.Unlock() + r.replies = append(r.replies, fakeReply{id: req.ID, allow: allow}) + 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() + r.closed++ + return nil +} + +type fakeRunner struct { + checkErr error + run *fakeRun + tasks []agent.Task +} + +func (f *fakeRunner) Check(context.Context) error { return f.checkErr } + +func (f *fakeRunner) Start(_ context.Context, task agent.Task) (agent.Run, error) { + f.tasks = append(f.tasks, task) + return f.run, nil +} + +func agentWizard(mode agentMode, runner agent.Runner) wizardModel { + return wizardModel{ + step: stepCreateFlag, + width: 80, + height: 30, + agentMode: mode, + agentRunner: runner, + agentRuns: &runTracker{}, + sdkKey: "sdk-123", + detectResult: &setup.DetectResult{SDKID: "node-server", Language: "JavaScript", EntryPoint: "index.js", EntryPointExists: true}, + } +} + +func update(t *testing.T, m wizardModel, msg tea.Msg) (wizardModel, tea.Cmd) { + t.Helper() + next, cmd := m.Update(msg) + return next.(wizardModel), cmd +} + +// startedAgent drives the wizard to a running agent step. +func startedAgent(t *testing.T, mode agentMode, run *fakeRun) wizardModel { + t.Helper() + runner := &fakeRunner{run: run} + m, cmd := update(t, agentWizard(mode, runner), flagCreatedMsg{key: "my-new-flag"}) + require.Equal(t, stepAgent, m.step) + require.NotNil(t, cmd) + started, ok := cmd().(agentStartedMsg) + require.True(t, ok) + m, _ = update(t, m, started) + require.Len(t, runner.tasks, 1) + return m +} + +func TestParseAgentMode(t *testing.T) { + for in, want := range map[string]agentMode{"auto": agentAuto, "ON": agentOn, " off ": agentOff} { + got, err := parseAgentMode(in) + require.NoError(t, err) + assert.Equal(t, want, got) + } + _, err := parseAgentMode("sometimes") + assert.Error(t, err) +} + +func TestWizard_AgentOff_UsesInit(t *testing.T) { + m, _ := update(t, agentWizard(agentOff, &fakeRunner{}), flagCreatedMsg{key: "f"}) + + assert.Equal(t, stepInit, m.step) +} + +func TestWizard_AgentAuto_StartsAgentWithTheTask(t *testing.T) { + runner := &fakeRunner{run: newFakeRun()} + m, cmd := update(t, agentWizard(agentAuto, runner), flagCreatedMsg{key: "my-new-flag"}) + require.Equal(t, stepAgent, m.step) + + msg := cmd() + + require.IsType(t, agentStartedMsg{}, msg) + require.Len(t, runner.tasks, 1) + assert.Equal(t, "node-server", runner.tasks[0].SDKID) + 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 the agent") +} + +func TestWizard_AgentUnavailable_AutoFallsBackToInit(t *testing.T) { + m, cmd := update(t, agentWizard(agentAuto, &fakeRunner{checkErr: agent.ErrNotInstalled}), flagCreatedMsg{key: "f"}) + + m, _ = update(t, m, cmd()) + + assert.Equal(t, stepInit, m.step) + assert.Contains(t, m.agentNote, "OpenCode is not installed") + assert.Nil(t, m.err) +} + +func TestWizard_AgentUnavailable_OnStopsWithError(t *testing.T) { + m, cmd := update(t, agentWizard(agentOn, &fakeRunner{checkErr: agent.ErrNotInstalled}), flagCreatedMsg{key: "f"}) + + m, _ = update(t, m, cmd()) + + require.Error(t, m.err) + assert.Contains(t, m.View(), "OpenCode is not installed") +} + +func TestWizard_AgentEvents_LogToolsAndFiles(t *testing.T) { + run := newFakeRun() + m := startedAgent(t, agentAuto, run) + + m, cmd := update(t, m, agentEventMsg{run: run, ev: agent.Event{Kind: agent.EventTool, Tool: agent.ToolCall{Name: "edit", Status: "completed", Title: "index.js"}}}) + require.NotNil(t, cmd, "the wizard asks for the next event") + m, _ = update(t, m, agentEventMsg{run: run, ev: agent.Event{Kind: agent.EventFileEdited, File: "index.js"}}) + m, _ = update(t, m, agentEventMsg{run: run, ev: agent.Event{Kind: agent.EventFileEdited, File: "index.js"}}) + + assert.Equal(t, []string{"index.js"}, m.agentFiles) + assert.Contains(t, m.View(), "✓ edit: index.js") +} + +func TestWizard_AgentPermission_YAllows(t *testing.T) { + run := newFakeRun() + m := startedAgent(t, agentAuto, run) + 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(), "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.Empty(t, m.agentPermissions) + assert.Equal(t, []fakeReply{{id: "per1", allow: true}}, run.replies) +} + +func TestWizard_AgentPermission_NRejects(t *testing.T) { + run := newFakeRun() + m := startedAgent(t, agentAuto, run) + + m, _ = update(t, m, agentEventMsg{run: run, ev: agent.Event{Kind: agent.EventPermission, Permission: agent.PermissionRequest{ID: "per1"}}}) + _, cmd := update(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}) + cmd() + + assert.Equal(t, []fakeReply{{id: "per1", allow: false}}, run.replies) +} + +func TestWizard_AgentDone_GoesToWaitForAppWithExports(t *testing.T) { + run := newFakeRun() + m := startedAgent(t, agentAuto, run) + m, _ = update(t, m, agentEventMsg{run: run, ev: agent.Event{Kind: agent.EventFileEdited, File: "index.js"}}) + + m, _ = update(t, m, agentEventMsg{run: run, ev: agent.Event{Kind: agent.EventDone}}) + + assert.Equal(t, stepWaitForApp, m.step) + assert.Equal(t, 1, run.closed) + 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) + assert.NotNil(t, cmd) +} + +func TestWizard_AgentDone_InstallFailed_GoesToDone(t *testing.T) { + run := newFakeRun() + m := startedAgent(t, agentAuto, run) + m.installResult = &setup.InstallResult{Failed: true, Command: "npm install x"} + + m, _ = update(t, m, agentEventMsg{run: run, ev: agent.Event{Kind: agent.EventDone}}) + + assert.Equal(t, stepDone, m.step) +} + +func TestWizard_AgentError_NoEdits_AutoFallsBack(t *testing.T) { + run := newFakeRun() + m := startedAgent(t, agentAuto, run) + + m, cmd := update(t, m, agentEventMsg{run: run, ev: agent.Event{Kind: agent.EventError, Text: "no API key"}}) + + assert.Equal(t, stepInit, m.step) + assert.NotNil(t, cmd) + assert.Contains(t, m.agentNote, "no API key") + assert.Equal(t, 1, run.closed) +} + +func TestWizard_AgentError_AfterEdits_DoesNotFallBack(t *testing.T) { + run := newFakeRun() + m := startedAgent(t, agentAuto, run) + m, _ = update(t, m, agentEventMsg{run: run, ev: agent.Event{Kind: agent.EventFileEdited, File: "index.js"}}) + + m, _ = update(t, m, agentEventMsg{run: run, ev: agent.Event{Kind: agent.EventError, Text: "boom"}}) + + require.Error(t, m.err) + assert.Equal(t, stepAgent, m.step) +} + +func TestWizard_AgentEvent_FromOldRunIsIgnored(t *testing.T) { + run := newFakeRun() + m := startedAgent(t, agentAuto, run) + + m, cmd := update(t, m, agentEventMsg{run: newFakeRun(), ev: agent.Event{Kind: agent.EventDone}}) + + assert.Equal(t, stepAgent, m.step) + assert.Nil(t, cmd) +} + +func TestWizard_Quit_ClosesAgent(t *testing.T) { + run := newFakeRun() + m := startedAgent(t, agentAuto, run) + + _, cmd := update(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")}) + + assert.True(t, quitsOn(cmd)) + assert.Equal(t, 1, run.closed) +} + +func TestRunTracker_ClosesRunsAndLateStarts(t *testing.T) { + tracker := &runTracker{} + early := newFakeRun() + tracker.add(early) + + tracker.closeAll() + late := newFakeRun() + tracker.add(late) + + assert.Equal(t, 1, early.closed) + assert.Equal(t, 1, late.closed, "a run that starts after setup ends is closed at once") +} + +func TestWizard_Plan_AgentMode_SaysAgent(t *testing.T) { + m := agentWizard(agentAuto, &fakeRunner{}) + m.step = stepPlan + m.selectedProject, m.selectedEnv = "proj", "prod" + + view := m.View() + + assert.Contains(t, view, "Use the setup agent to add initialization code") + assert.Contains(t, view, "uses the template if the agent cannot run") +} + +func TestStreamAgent_AnswersPermissionAndReturnsFiles(t *testing.T) { + run := newFakeRun( + agent.Event{Kind: agent.EventText, Text: "Working"}, + agent.Event{Kind: agent.EventPermission, Permission: agent.PermissionRequest{ID: "per1", Permission: "bash", Patterns: []string{"npm test"}}}, + agent.Event{Kind: agent.EventFileEdited, File: "b.js"}, + agent.Event{Kind: agent.EventFileEdited, File: "a.js"}, + agent.Event{Kind: agent.EventDone}, + ) + var out bytes.Buffer + + files, err := streamAgent(context.Background(), run, &out, bufio.NewReader(strings.NewReader("y\n"))) + + 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(), "The agent asks to use bash: npm test") +} + +func TestStreamAgent_EmptyAnswerRejects(t *testing.T) { + run := newFakeRun( + agent.Event{Kind: agent.EventPermission, Permission: agent.PermissionRequest{ID: "per1"}}, + agent.Event{Kind: agent.EventDone}, + ) + + _, err := streamAgent(context.Background(), run, &bytes.Buffer{}, bufio.NewReader(strings.NewReader("\n"))) + + require.NoError(t, err) + assert.Equal(t, []fakeReply{{id: "per1", allow: false}}, run.replies) +} + +func TestStreamAgent_Error(t *testing.T) { + run := newFakeRun(agent.Event{Kind: agent.EventError, Text: "no API key"}) + + _, err := streamAgent(context.Background(), run, &bytes.Buffer{}, bufio.NewReader(strings.NewReader(""))) + + require.Error(t, err) + assert.Contains(t, err.Error(), "no API key") +} + +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 85bd479e7..54e298266 100644 --- a/cmd/setup/commands.go +++ b/cmd/setup/commands.go @@ -1,6 +1,7 @@ package setup import ( + "context" "fmt" "io" "os" @@ -11,6 +12,7 @@ import ( "golang.org/x/term" "github.com/launchdarkly/ldcli/internal/setup" + "github.com/launchdarkly/ldcli/internal/setup/agent" ) func (m wizardModel) fetchProjects() tea.Cmd { @@ -199,3 +201,53 @@ func isRemoteSession() bool { } return false } + +// startAgent checks that the agent can run and starts it. +func (m wizardModel) startAgent() tea.Cmd { + runner, tracker := m.agentRunner, m.agentRuns + sdkInstalled := m.installResult != nil && !m.installResult.Failed + task := agentTask("", m.detectResult, m.flagKey, sdkInstalled) + return func() tea.Msg { + dir, err := os.Getwd() + if err != nil { + return agentFailedMsg{err: err} + } + task.Dir = dir + ctx := context.Background() + if err := runner.Check(ctx); err != nil { + return agentFailedMsg{err: err} + } + run, err := runner.Start(ctx, task) + if err != nil { + return agentFailedMsg{err: err} + } + if tracker != nil { + tracker.add(run) + } + return agentStartedMsg{run: run} + } +} + +// waitForAgentEvent returns the run's next event. Update calls it again after +// each event, so the wizard reads the events one at a time. +func waitForAgentEvent(run agent.Run) tea.Cmd { + return func() tea.Msg { + ev, ok := <-run.Events() + if !ok { + return agentEventMsg{run: run, closed: true} + } + return agentEventMsg{run: run, ev: ev} + } +} + +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 fe09b7c93..1eeb33206 100644 --- a/cmd/setup/model.go +++ b/cmd/setup/model.go @@ -2,6 +2,10 @@ package setup import ( "io" + "os" + "os/signal" + "sync" + "syscall" "github.com/atotto/clipboard" "github.com/charmbracelet/bubbles/list" @@ -14,6 +18,7 @@ import ( "github.com/launchdarkly/ldcli/internal/analytics" "github.com/launchdarkly/ldcli/internal/errors" "github.com/launchdarkly/ldcli/internal/setup" + "github.com/launchdarkly/ldcli/internal/setup/agent" ) // copyState records how the visible snippet was copied, so the view can confirm a @@ -39,6 +44,9 @@ const ( stepPlan stepInstall stepCreateFlag + // stepAgent runs the setup agent. It replaces stepInit when the agent can + // run, and it falls back to stepInit in auto mode when the agent fails. + stepAgent stepInit stepWaitForApp stepVerify @@ -99,6 +107,31 @@ type wizardModel struct { initResult *setup.InitResult verifyResult *setup.VerifyResult + agentMode agentMode + agentRunner agent.Runner + // agentRuns records every run that starts, so setup can close a run whose + // start finished after the user quit. + agentRuns *runTracker + agentRun agent.Run + // agentLog holds one line per finished tool call, newest last. + agentLog []string + // agentFiles are the files that the agent changed. + agentFiles []string + // 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 + // nativeCopy puts content on the operating system's clipboard, and clipboard // receives the OSC 52 sequence used when that is not available. Both are fields // so tests can drive either path without a real clipboard or terminal. @@ -188,13 +221,28 @@ type flagCreatedMsg struct{ key string } type initDoneMsg struct{ result *setup.InitResult } type copiedMsg struct{ viaTerminal bool } type verifyDoneMsg struct{ result *setup.VerifyResult } +type agentStartedMsg struct{ run agent.Run } +type agentFailedMsg struct{ err error } +type agentEventMsg struct { + run agent.Run + ev agent.Event + // closed reports that the run's event channel closed without a last event. + closed bool +} +type agentRepliedMsg struct{ err error } type wizardErrMsg struct{ err error } func runSetupWizard( analyticsTrackerFn analytics.TrackerFn, svc setup.Service, + runner agent.Runner, ) func(*cobra.Command, []string) error { return func(cmd *cobra.Command, args []string) error { + mode, err := parseAgentMode(getFlag(cmd, agentFlag)) + if err != nil { + return err + } + // Pre-flight: the wizard's first action is an authenticated API call, so // bail early with clear guidance rather than dumping a raw 401 mid-TUI. if viper.GetString(cliflags.AccessTokenFlag) == "" { @@ -216,14 +264,60 @@ func runSetupWizard( clipboard: terminalWriter(), nativeCopy: clipboard.WriteAll, remoteSession: isRemoteSession(), + agentMode: mode, + agentRunner: runner, + agentRuns: &runTracker{}, } p := tea.NewProgram(m, tea.WithAltScreen()) - _, err := p.Run() + // 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. + m.agentRuns.closeAll() return err } } +// runTracker records agent runs. It is a pointer in the model, so every copy +// of the model shares one tracker. +type runTracker struct { + mu sync.Mutex + runs []agent.Run + closed bool +} + +// add records a run. If the tracker is already closed, it closes the run. +func (t *runTracker) add(run agent.Run) { + t.mu.Lock() + defer t.mu.Unlock() + if t.closed { + _ = run.Close() + return + } + t.runs = append(t.runs, run) +} + +func (t *runTracker) closeAll() { + t.mu.Lock() + defer t.mu.Unlock() + t.closed = true + for _, r := range t.runs { + _ = r.Close() + } + t.runs = nil +} + func (m wizardModel) Init() tea.Cmd { // Detect the project once, up front, so navigating the flow never re-runs it. return tea.Batch(m.spinner.Tick, m.fetchProjects(), m.runDetect()) diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index b0f32f95c..8fe4ae053 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -10,6 +10,7 @@ import ( "github.com/launchdarkly/ldcli/cmd/cliflags" "github.com/launchdarkly/ldcli/internal/analytics" "github.com/launchdarkly/ldcli/internal/setup" + "github.com/launchdarkly/ldcli/internal/setup/agent" ) // NewSetupCmd creates the top-level setup command and registers its hidden subcommands. @@ -25,6 +26,7 @@ func NewSetupCmd( Installer: installer, Initializer: setup.Initializer{}, } + runner := agent.NewOpenCode() cmd := &cobra.Command{ Use: "setup", Short: "Set up LaunchDarkly in your project", @@ -32,7 +34,10 @@ func NewSetupCmd( Detects your project's language and framework, installs the correct SDK, initializes it with your environment's SDK key, creates a feature flag, -and verifies the connection.`, +and verifies the connection. + +With --agent=auto (the default), setup uses the setup agent to add the +initialization code if OpenCode is installed, and the built-in templates if not.`, PreRun: func(cmd *cobra.Command, args []string) { // Dim the notice and set it off with a blank line so it reads as a // transitional notice, visually distinct from command output. @@ -46,12 +51,14 @@ and verifies the connection.`, viper.GetBool(cliflags.AnalyticsOptOut), ).SendCommandRunEvent(cmdAnalytics.CmdRunEventProperties(cmd, "setup", nil)) }, - RunE: runSetupWizard(analyticsTrackerFn, svc), + RunE: 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)) return cmd } diff --git a/cmd/setup/update.go b/cmd/setup/update.go index a484a3c99..0bbc6719e 100644 --- a/cmd/setup/update.go +++ b/cmd/setup/update.go @@ -1,7 +1,9 @@ package setup import ( + "errors" "os" + "slices" "strings" "github.com/charmbracelet/bubbles/key" @@ -10,6 +12,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/launchdarkly/ldcli/internal/setup" + "github.com/launchdarkly/ldcli/internal/setup/agent" ) func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -37,6 +40,7 @@ func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.String() { case "ctrl+c": m.quitting = true + m.closeAgent() return m, tea.Quit // esc is not used due to arrow keys becoming the escape sequence. case "q": @@ -44,7 +48,18 @@ func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { break // let the list receive 'q' as filter input } m.quitting = true + m.closeAgent() return m, tea.Quit + case "y", "n": + 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 @@ -143,9 +158,29 @@ func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case flagCreatedMsg: m.flagKey = msg.key + if m.agentMode != agentOff && m.agentRunner != nil { + m.step = stepAgent + return m, m.startAgent() + } m.step = stepInit return m, m.runInit() + case agentStartedMsg: + m.agentRun = msg.run + return m, waitForAgentEvent(msg.run) + + case agentFailedMsg: + return m.agentFailed(msg.err) + + case agentRepliedMsg: + if msg.err != nil { + return m.agentFailed(msg.err) + } + return m, nil + + case agentEventMsg: + return m.handleAgentEvent(msg) + case initDoneMsg: m.initResult = msg.result // Skip the live verify if init didn't inject runnable code, or if the SDK @@ -217,6 +252,102 @@ func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } +// handleAgentEvent applies one event of the agent run and asks for the next. +func (m wizardModel) handleAgentEvent(msg agentEventMsg) (tea.Model, tea.Cmd) { + // An event of a run that already ended, for example after a fallback, has + // nothing left to change. + if m.agentRun == nil || msg.run != m.agentRun { + return m, nil + } + if msg.closed { + return m.agentFailed(errors.New("the setup agent stopped without a result")) + } + ev := msg.ev + switch ev.Kind { + case agent.EventTool: + if ev.Tool.Status != "running" { + m.agentLog = append(m.agentLog, describeTool(ev.Tool)) + } + case agent.EventFileEdited: + if !slices.Contains(m.agentFiles, ev.File) { + m.agentFiles = append(m.agentFiles, ev.File) + } + case agent.EventUsage: + u := ev.Usage + m.agentUsage = &u + case agent.EventPermission: + req := ev.Permission + m.agentPermissions = append(m.agentPermissions, req) + case agent.EventQuestion: + m.agentQuestions = append(m.agentQuestions, ev.Question) + case agent.EventDone: + m.agentDone = true + m.closeAgent() + // As after init: without an installed SDK, the app cannot connect. + if m.installResult != nil && m.installResult.Failed { + m.step = stepDone + return m, nil + } + m.step = stepWaitForApp + return m, nil + case agent.EventError: + return m.agentFailed(errors.New(ev.Text)) + } + return m, waitForAgentEvent(m.agentRun) +} + +// agentFailed ends the agent run. In auto mode, setup continues with the init +// templates, but only if the agent changed no files: a template on top of a +// partial edit can initialize the SDK twice. +func (m wizardModel) agentFailed(err error) (tea.Model, tea.Cmd) { + m.closeAgent() + m.agentRun = nil + m.agentPermissions = nil + m.agentQuestions = nil + m.agentAnswers = nil + if m.agentMode == agentOn || len(m.agentFiles) > 0 { + m.err = errors.New("The setup agent failed: " + err.Error()) + return m, nil + } + m.agentNote = "The setup agent could not run, so setup used the built-in template. " + err.Error() + m.agentLog = nil + m.step = stepInit + 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 { + _ = m.agentRun.Close() + } +} + // keepEscFromQuitting rebinds a list's quit key to q alone. bubbles binds it to // both q and esc, and returns tea.Quit when either matches, so removing our own esc // binding was not enough — the key fell through to whichever list was on screen and diff --git a/cmd/setup/view.go b/cmd/setup/view.go index 7f1abcd8c..691c58910 100644 --- a/cmd/setup/view.go +++ b/cmd/setup/view.go @@ -37,6 +37,16 @@ func (m wizardModel) View() string { return titleStyle.Render("Error") + "\n\n" + m.err.Error() + "\n\nPress ctrl+c to quit." } + view := m.stepView() + // Say why setup used a template on every screen that follows, so the user + // knows that the agent did not run. + if m.agentNote != "" && (m.step == stepWaitForApp || m.step == stepDone) { + view = mutedStyle.Render(m.wrap("Note: "+m.agentNote)) + "\n\n" + view + } + return view +} + +func (m wizardModel) stepView() string { switch m.step { case stepSelectProject: if !m.projectsLoaded { @@ -81,7 +91,13 @@ func (m wizardModel) View() string { case stepInit: return m.spinner.View() + " Injecting initialization code..." + case stepAgent: + return m.agentView() + case stepWaitForApp: + if m.agentDone { + return m.agentWaitView() + } // The newline stays outside the wrap: wrapping pads each line to the full // width, so a trailing one inside would put a row of spaces in front of the // path and push it past the edge of the terminal. @@ -364,7 +380,14 @@ func (m wizardModel) planView() string { add(fmt.Sprintf("Add the %s SDK %s", name, mutedStyle.Render("(manual install)"))) } add(fmt.Sprintf("Create a feature flag in %s / %s", m.selectedProject, m.selectedEnv)) - if setup.InjectsInPlace(m.detectResult.SDKID) { + if m.agentMode != agentOff && m.agentRunner != nil { + note := "(asks you only when it needs information)" + if m.agentMode == agentAuto { + 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") + } else if setup.InjectsInPlace(m.detectResult.SDKID) { // Say when the entry file does not exist yet: a file we create is not loaded // by the project, so the user needs the chance to back out and point us at // the real entry point. @@ -389,3 +412,83 @@ func (m wizardModel) planView() string { // orchestration service: it calls a step method and maps the result or error // onto a wizard message. All API/filesystem work and business rules live in // internal/setup.Service. + +// agentLogLines is how many finished tool calls the agent screen shows. +const agentLogLines = 8 + +// agentView shows the agent's progress, and the question when a tool call +// waits for the user's answer. +func (m wizardModel) agentView() string { + body := titleStyle.Render("Setting up the SDK with the setup agent") + "\n\n" + log := m.agentLog + if len(log) > agentLogLines { + log = log[len(log)-agentLogLines:] + } + for _, line := range log { + body += m.wrap(line) + "\n" + } + if len(log) > 0 { + body += "\n" + } + 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.agentPermissions[0]), m.sdkBoxWidth()-4) + "\n" + + mutedStyle.Render(hint)) + return body + question + "\n" + } + if len(m.agentQuestions) > 0 { + req := m.agentQuestions[0] + question := req.Questions[len(m.agentAnswers)] + width := m.sdkBoxWidth() - 4 + text := headerStyle.Render(question.Header) + "\n" + wrapText(question.Text, width) + "\n\n" + for i, o := range question.Options { + line := fmt.Sprintf("%d. %s", i+1, o.Label) + if o.Description != "" { + line += " — " + o.Description + } + text += wrapText(line, width) + "\n" + } + hint := fmt.Sprintf("1-%d choose · s skip", len(question.Options)) + if total := len(req.Questions); total > 1 { + hint = fmt.Sprintf("question %d of %d · ", len(m.agentAnswers)+1, total) + hint + } + return body + box(true, m.sdkBoxWidth()).Render(text+"\n"+mutedStyle.Render(hint)) + "\n" + } + status := " The agent is working..." + if m.agentRun == nil { + status = " Starting the agent..." + } + return body + m.spinner.View() + status + "\n\n" + mutedStyle.Render("q quit") +} + +// agentWaitView replaces the wait-for-app screen after an agent run. The +// agent's code reads the credential from an environment variable, so the +// user must set it before the application starts. +func (m wizardModel) agentWaitView() string { + body := titleStyle.Render("Start your application") + "\n\n" + if len(m.agentFiles) == 0 { + body += m.wrap("The setup agent finished but did not report changed files.") + "\n\n" + } else { + body += m.wrap("The setup agent changed these files:") + "\n" + for _, f := range m.agentFiles { + body += m.wrap(" "+f) + "\n" + } + body += "\n" + } + 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(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" + } + return body + m.wrap("Start your application now, then press Enter to verify the connection.") + "\n" +} diff --git a/internal/setup/agent/agent.go b/internal/setup/agent/agent.go new file mode 100644 index 000000000..7116c0584 --- /dev/null +++ b/internal/setup/agent/agent.go @@ -0,0 +1,168 @@ +// Package agent runs the setup agent: an OpenCode session that adds LaunchDarkly +// SDK initialization code to the user's project. +// +// The CLI owns the OpenCode process. It starts OpenCode in the project +// directory, sends one task, turns the session events into Events, and stops +// OpenCode when the run ends. OpenCode edits the files. The CLI never gives +// OpenCode a LaunchDarkly credential. +package agent + +import "context" + +// Task describes the project and what the agent must do in it. +type Task struct { + // Dir is the project directory that OpenCode edits. + Dir string + SDKID string + Language string + Framework string + EntryPoint string + PackageManager string + // FlagKey is the flag that the new code evaluates. + FlagKey string + // SDKInstalled reports that the install step added the SDK package, so the + // agent does not need to install it. + SDKInstalled bool +} + +// Config is how the agent behaves for one run: the model and the instructions. +type Config struct { + // ProviderID and ModelID select the OpenCode model. If both are empty, + // OpenCode uses its own default model. + ProviderID string + ModelID string + Instructions string +} + +// ConfigSource gives the agent configuration for a task. +// +// The setup-agent AI Config will be the source when the Go AI SDK has an +// OpenCode handler. Until then, DefaultConfigSource supplies built-in +// instructions. +type ConfigSource interface { + Resolve(ctx context.Context, task Task) (Config, error) +} + +// DefaultConfigSource returns the built-in instructions and no model, so +// OpenCode uses the model that the user configured for it. +type DefaultConfigSource struct{} + +func (DefaultConfigSource) Resolve(context.Context, Task) (Config, error) { + return Config{Instructions: DefaultInstructions}, nil +} + +// EventKind identifies what an Event reports. +type EventKind int + +const ( + // EventText carries a part of the assistant's reply in Text. + EventText EventKind = iota + // EventTool reports a tool call that started, finished, or failed. + 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. + EventUsage + // EventDone reports that the session finished. It is the last event. + EventDone + // EventError reports that the run failed, with the reason in Text. It is + // the last event. + EventError +) + +// Event is one thing that happened in the agent run. +type Event struct { + Kind EventKind + Text string + Tool ToolCall + Permission PermissionRequest + Question QuestionRequest + File string + Usage Usage +} + +// ToolCall is the state of one tool call. +type ToolCall struct { + ID string + // Name is the tool, for example "edit" or "bash". + Name string + // Status is "running", "completed", or "error". + Status string + // Title is OpenCode's short description of the call, if it has one. + Title string +} + +// PermissionRequest is a tool call that waits for the user's answer. +type PermissionRequest struct { + ID string + SessionID string + // Permission is the permission type, for example "bash" or "edit". + Permission string + // Patterns are what the call applies to, for example the command to run. + Patterns []string + // legacy marks a request from an OpenCode version that uses the + // session-scoped reply endpoint. + 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 + ModelID string + InputTokens int + OutputTokens int + Cost float64 +} + +// Runner starts agent runs. +type Runner interface { + // Check returns an error that tells the user why the agent cannot run, or + // nil if it can. + Check(ctx context.Context) error + // Start begins a run for the task. The caller must Close the run. + Start(ctx context.Context, task Task) (Run, error) +} + +// Run is one agent run in progress. +type Run interface { + // Events delivers the run's events. The channel closes after EventDone or + // EventError. + 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 new file mode 100644 index 000000000..ee4234797 --- /dev/null +++ b/internal/setup/agent/agent_test.go @@ -0,0 +1,388 @@ +package agent + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildPrompt_NamesTheTaskAndNoCredential(t *testing.T) { + prompt := BuildPrompt(Task{ + SDKID: "node-server", + Language: "JavaScript", + PackageManager: "npm", + EntryPoint: "index.js", + FlagKey: "my-new-flag", + SDKInstalled: true, + }) + + assert.Contains(t, prompt, `"node-server"`) + assert.Contains(t, prompt, `"my-new-flag"`) + assert.Contains(t, prompt, "- Entry point: index.js") + assert.Contains(t, prompt, "- Framework: unknown") + assert.Contains(t, prompt, "already installed") + assert.Contains(t, prompt, "environment variable "+SDKKeyEnvVar) + assert.NotContains(t, prompt, MobileKeyEnvVar, "the prompt names only the SDK's own credential") + assert.NotContains(t, prompt, "did not find an entry point") +} + +func TestCredentialEnvVar(t *testing.T) { + assert.Equal(t, SDKKeyEnvVar, CredentialEnvVar("python-server-sdk")) + assert.Equal(t, ClientSideIDEnvVar, CredentialEnvVar("js-client-sdk")) + assert.Equal(t, MobileKeyEnvVar, CredentialEnvVar("swift-client-sdk")) +} + +func TestBuildPrompt_NoEntryPoint_AsksTheAgentToFindOne(t *testing.T) { + prompt := BuildPrompt(Task{SDKID: "go-server-sdk", FlagKey: "f"}) + + assert.Contains(t, prompt, "did not find an entry point") + assert.Contains(t, prompt, "is not installed") +} + +func TestChildEnv_RemovesLDVariablesAndSetsOwn(t *testing.T) { + env := childEnv( + []string{"PATH=/bin", "LD_ACCESS_TOKEN=api-secret", "ld_base_uri=x", "OPENCODE_SERVER_PASSWORD=old", "HOME=/home/me"}, + map[string]string{"OPENCODE_SERVER_PASSWORD": "new"}, + ) + + assert.ElementsMatch(t, []string{"PATH=/bin", "HOME=/home/me", "OPENCODE_SERVER_PASSWORD=new"}, env) +} + +func TestOpenCodeConfig_RunsCommandsWithoutApprovals(t *testing.T) { + data, err := openCodeConfig() + require.NoError(t, err) + + var cfg struct { + Permission map[string]json.RawMessage `json:"permission"` + } + require.NoError(t, json.Unmarshal(data, &cfg)) + 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) { + assert.True(t, versionAtLeast("1.19.0", "1.19.0")) + assert.True(t, versionAtLeast("v1.19.3", "1.19.0")) + assert.True(t, versionAtLeast("opencode 2.0.1\n", "1.19.0")) + assert.False(t, versionAtLeast("1.8.9", "1.19.0")) + assert.False(t, versionAtLeast("dev", "1.19.0")) +} + +func TestCheck_NotInstalled(t *testing.T) { + o := &OpenCode{lookPath: func(string) (string, error) { return "", fmt.Errorf("not found") }} + + assert.ErrorIs(t, o.Check(context.Background()), ErrNotInstalled) +} + +func TestCheck_TooOld(t *testing.T) { + o := &OpenCode{ + lookPath: func(string) (string, error) { return "/bin/opencode", nil }, + version: func(context.Context, string) (string, error) { return "1.2.0", nil }, + } + + err := o.Check(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "too old") +} + +func TestCheck_Supported(t *testing.T) { + o := &OpenCode{ + lookPath: func(string) (string, error) { return "/bin/opencode", nil }, + version: func(context.Context, string) (string, error) { return "1.19.3", nil }, + } + + assert.NoError(t, o.Check(context.Background())) +} + +func TestReadSSE_JoinsDataLinesAndSkipsOtherFields(t *testing.T) { + stream := "event: message\nid: 1\ndata: {\"a\":\ndata: 1}\n\n: comment\ndata: {\"b\":2}\n\n" + var got []string + + err := readSSE(strings.NewReader(stream), func(data []byte) bool { + got = append(got, string(data)) + return true + }) + + require.NoError(t, err) + assert.Equal(t, []string{"{\"a\":\n1}", "{\"b\":2}"}, got) +} + +func decodeAll(d *decoder, events ...string) []Event { + var out []Event + for _, e := range events { + out = append(out, d.decode([]byte(e))...) + } + return out +} + +func TestDecoder_TextDeltasOnlyForTextParts(t *testing.T) { + d := newDecoder("ses") + + out := decodeAll(d, + `{"type":"message.part.updated","properties":{"part":{"id":"p1","sessionID":"ses","type":"reasoning"}}}`, + `{"type":"message.part.delta","properties":{"sessionID":"ses","partID":"p1","field":"text","delta":"thinking"}}`, + `{"type":"message.part.updated","properties":{"part":{"id":"p2","sessionID":"ses","type":"text"}}}`, + `{"type":"message.part.delta","properties":{"sessionID":"ses","partID":"p2","field":"text","delta":"Hello"}}`, + ) + + require.Len(t, out, 1) + assert.Equal(t, Event{Kind: EventText, Text: "Hello"}, out[0]) +} + +func TestDecoder_IgnoresOtherSessions(t *testing.T) { + d := newDecoder("ses") + + out := decodeAll(d, + `{"type":"message.part.updated","properties":{"part":{"id":"p1","sessionID":"other","type":"tool","callID":"c1","tool":"bash","state":{"status":"running"}}}}`, + `{"type":"permission.asked","properties":{"id":"per1","sessionID":"other","permission":"bash","patterns":["rm -rf /"]}}`, + `{"type":"session.idle","properties":{"sessionID":"other"}}`, + ) + + assert.Empty(t, out) +} + +func TestDecoder_ToolStatusChangesOnce(t *testing.T) { + d := newDecoder("ses") + tool := func(status string) string { + return fmt.Sprintf(`{"type":"message.part.updated","properties":{"part":{"id":"p1","sessionID":"ses","type":"tool","callID":"c1","tool":"edit","state":{"status":%q,"title":"index.js"}}}}`, status) + } + + out := decodeAll(d, tool("pending"), tool("running"), tool("running"), tool("completed")) + + require.Len(t, out, 2) + assert.Equal(t, ToolCall{ID: "c1", Name: "edit", Status: "running", Title: "index.js"}, out[0].Tool) + assert.Equal(t, "completed", out[1].Tool.Status) +} + +func TestDecoder_Permission(t *testing.T) { + d := newDecoder("ses") + + out := decodeAll(d, `{"type":"permission.asked","properties":{"id":"per1","sessionID":"ses","permission":"bash","patterns":["npm test"]}}`) + + require.Len(t, out, 1) + assert.Equal(t, EventPermission, out[0].Kind) + 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") + + out := decodeAll(d, `{"type":"permission.updated","properties":{"id":"per1","sessionID":"ses","type":"bash","pattern":"npm test","title":"Run npm test"}}`) + + require.Len(t, out, 1) + assert.True(t, out[0].Permission.legacy) + assert.Equal(t, []string{"npm test"}, out[0].Permission.Patterns) +} + +func TestDecoder_UsageOncePerFinishedReply(t *testing.T) { + d := newDecoder("ses") + msg := func(completed int) string { + return fmt.Sprintf(`{"type":"message.updated","properties":{"info":{"id":"m1","sessionID":"ses","role":"assistant","providerID":"anthropic","modelID":"claude","cost":0.5,"time":{"created":1,"completed":%d},"tokens":{"input":10,"output":20}}}}`, completed) + } + + out := decodeAll(d, msg(0), msg(2), msg(2)) + + require.Len(t, out, 1) + assert.Equal(t, Usage{ProviderID: "anthropic", ModelID: "claude", InputTokens: 10, OutputTokens: 20, Cost: 0.5}, out[0].Usage) +} + +func TestDecoder_StopsAfterDoneOrError(t *testing.T) { + d := newDecoder("ses") + + out := decodeAll(d, + `{"type":"session.error","properties":{"sessionID":"ses","error":{"name":"ProviderAuthError","data":{"message":"no API key"}}}}`, + `{"type":"session.idle","properties":{"sessionID":"ses"}}`, + ) + + require.Len(t, out, 1) + assert.Equal(t, Event{Kind: EventError, Text: "no API key"}, out[0]) +} + +// fakeOpenCode is an HTTP server with the parts of the OpenCode API that a +// run uses. The test writes events to the stream with emit. +type fakeOpenCode struct { + t *testing.T + srv *httptest.Server + stream chan string + + mu sync.Mutex + prompt map[string]any + replies []string +} + +func newFakeOpenCode(t *testing.T) *fakeOpenCode { + f := &fakeOpenCode{t: t, stream: make(chan string, 16)} + mux := http.NewServeMux() + mux.HandleFunc("POST /session", func(w http.ResponseWriter, r *http.Request) { + if _, pass, ok := r.BasicAuth(); !ok || pass != "secret" { + w.WriteHeader(http.StatusUnauthorized) + return + } + _, _ = w.Write([]byte(`{"id":"ses"}`)) + }) + mux.HandleFunc("GET /event", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + w.(http.Flusher).Flush() + for { + select { + case <-r.Context().Done(): + return + case e := <-f.stream: + fmt.Fprintf(w, "data: %s\n\n", e) + w.(http.Flusher).Flush() + } + } + }) + mux.HandleFunc("POST /session/ses/prompt_async", func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + f.mu.Lock() + f.prompt = body + 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) + f.mu.Lock() + f.replies = append(f.replies, r.PathValue("id")+"="+body["reply"]) + f.mu.Unlock() + _, _ = w.Write([]byte("true")) + }) + f.srv = httptest.NewServer(mux) + t.Cleanup(f.srv.Close) + return f +} + +func (f *fakeOpenCode) client() *client { + return &client{baseURL: f.srv.URL, password: "secret", http: &http.Client{Timeout: 5 * time.Second}} +} + +func (f *fakeOpenCode) emit(e string) { f.stream <- e } + +func nextEvent(t *testing.T, run Run) (Event, bool) { + t.Helper() + select { + case ev, ok := <-run.Events(): + return ev, ok + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for an event") + return Event{}, false + } +} + +func TestRun_SendsTaskAndStreamsEventsUntilIdle(t *testing.T) { + f := newFakeOpenCode(t) + cfg := Config{ProviderID: "anthropic", ModelID: "claude", Instructions: "be brief"} + + run, err := startRun(context.Background(), f.client(), cfg, "set up the SDK") + require.NoError(t, err) + defer run.Close() + + f.mu.Lock() + assert.Equal(t, "be brief", f.prompt["system"]) + assert.Equal(t, map[string]any{"providerID": "anthropic", "modelID": "claude"}, f.prompt["model"]) + f.mu.Unlock() + + f.emit(`{"type":"file.edited","properties":{"file":"index.js"}}`) + f.emit(`{"type":"session.idle","properties":{"sessionID":"ses"}}`) + + ev, _ := nextEvent(t, run) + assert.Equal(t, Event{Kind: EventFileEdited, File: "index.js"}, ev) + ev, _ = nextEvent(t, run) + assert.Equal(t, EventDone, ev.Kind) + _, open := nextEvent(t, run) + assert.False(t, open, "the channel closes after the last event") +} + +func TestRun_RepliesToPermission(t *testing.T) { + f := newFakeOpenCode(t) + run, err := startRun(context.Background(), f.client(), Config{}, "task") + require.NoError(t, err) + defer run.Close() + + f.emit(`{"type":"permission.asked","properties":{"id":"per1","sessionID":"ses","permission":"bash","patterns":["npm test"]}}`) + ev, _ := nextEvent(t, run) + require.Equal(t, EventPermission, ev.Kind) + + require.NoError(t, run.Reply(context.Background(), ev.Permission, true)) + require.NoError(t, run.Reply(context.Background(), ev.Permission, false)) + + f.mu.Lock() + defer f.mu.Unlock() + 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") + require.NoError(t, err) + defer run.Close() + + f.srv.CloseClientConnections() + + ev, _ := nextEvent(t, run) + assert.Equal(t, EventError, ev.Kind) +} + +func TestRun_CloseIsSafeTwice(t *testing.T) { + f := newFakeOpenCode(t) + run, err := startRun(context.Background(), f.client(), Config{}, "task") + require.NoError(t, err) + + assert.NoError(t, run.Close()) + assert.NoError(t, run.Close()) +} diff --git a/internal/setup/agent/client.go b/internal/setup/agent/client.go new file mode 100644 index 000000000..82f68aa9e --- /dev/null +++ b/internal/setup/agent/client.go @@ -0,0 +1,147 @@ +package agent + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +// serverUsername is the basic auth user name that OpenCode expects when +// OPENCODE_SERVER_PASSWORD is set and OPENCODE_SERVER_USERNAME is not. +const serverUsername = "opencode" + +// client calls the HTTP API of one OpenCode server. +type client struct { + baseURL string + password string + http *http.Client +} + +func (c *client) newRequest(ctx context.Context, method, path string, body any) (*http.Request, error) { + var r io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + r = bytes.NewReader(data) + } + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, r) + if err != nil { + return nil, err + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + req.SetBasicAuth(serverUsername, c.password) + return req, nil +} + +// do sends a request and decodes a JSON response into out, if out is not nil. +func (c *client) do(ctx context.Context, method, path string, body, out any) error { + req, err := c.newRequest(ctx, method, path, body) + if err != nil { + return err + } + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + msg, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return &statusError{method: method, path: path, code: resp.StatusCode, body: strings.TrimSpace(string(msg))} + } + if out == nil { + return nil + } + return json.NewDecoder(resp.Body).Decode(out) +} + +type statusError struct { + method, path string + code int + body string +} + +func (e *statusError) Error() string { + if e.body == "" { + return fmt.Sprintf("OpenCode returned HTTP %d for %s %s", e.code, e.method, e.path) + } + return fmt.Sprintf("OpenCode returned HTTP %d for %s %s: %s", e.code, e.method, e.path, e.body) +} + +func (c *client) createSession(ctx context.Context) (string, error) { + var s struct { + ID string `json:"id"` + } + if err := c.do(ctx, http.MethodPost, "/session", map[string]string{"title": "ldcli setup"}, &s); err != nil { + return "", err + } + if s.ID == "" { + return "", fmt.Errorf("OpenCode created a session without an ID") + } + return s.ID, nil +} + +// promptAsync sends the task and returns when OpenCode accepts it. The reply +// arrives on the event stream. +func (c *client) promptAsync(ctx context.Context, sessionID string, cfg Config, text string) error { + body := map[string]any{ + "parts": []map[string]string{{"type": "text", "text": text}}, + } + if cfg.Instructions != "" { + body["system"] = cfg.Instructions + } + if cfg.ProviderID != "" && cfg.ModelID != "" { + body["model"] = map[string]string{"providerID": cfg.ProviderID, "modelID": cfg.ModelID} + } + return c.do(ctx, http.MethodPost, "/session/"+url.PathEscape(sessionID)+"/prompt_async", body, nil) +} + +func (c *client) replyPermission(ctx context.Context, req PermissionRequest, allow bool) error { + reply := "reject" + if allow { + reply = "once" + } + if req.legacy { + path := "/session/" + url.PathEscape(req.SessionID) + "/permissions/" + url.PathEscape(req.ID) + return c.do(ctx, http.MethodPost, path, map[string]string{"response": reply}, nil) + } + 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) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "text/event-stream") + // The stream stays open for the whole run, so it must not use the client + // timeout of the other calls. + resp, err := (&http.Client{Transport: c.http.Transport}).Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + return nil, &statusError{method: http.MethodGet, path: "/event", code: resp.StatusCode} + } + return resp.Body, nil +} diff --git a/internal/setup/agent/events.go b/internal/setup/agent/events.go new file mode 100644 index 000000000..fe3ad486b --- /dev/null +++ b/internal/setup/agent/events.go @@ -0,0 +1,328 @@ +package agent + +import ( + "bufio" + "bytes" + "encoding/json" + "io" + "strings" +) + +// rawEvent is one OpenCode bus event as the /event stream sends it. +type rawEvent struct { + Type string `json:"type"` + Properties json.RawMessage `json:"properties"` +} + +// decoder turns OpenCode bus events into Events for one session. OpenCode +// sends events for every session on the server, so the decoder drops the +// events of other sessions. It also drops repeats: OpenCode sends a part again +// each time that the part changes. +type decoder struct { + sessionID string + // partTypes records the type of each part, because a text delta does not + // say whether it belongs to a text part or a reasoning part. + partTypes map[string]string + // toolStatus records the last status sent for each tool call. + toolStatus map[string]string + usageSent map[string]bool + finished bool +} + +func newDecoder(sessionID string) *decoder { + return &decoder{ + sessionID: sessionID, + partTypes: map[string]string{}, + toolStatus: map[string]string{}, + usageSent: map[string]bool{}, + } +} + +// decode returns the Events for one raw event. After the decoder returns +// EventDone or EventError, it returns nothing. +func (d *decoder) decode(data []byte) []Event { + if d.finished { + return nil + } + var ev rawEvent + if err := json.Unmarshal(data, &ev); err != nil { + return nil + } + var out []Event + switch ev.Type { + case "message.part.updated": + out = d.partUpdated(ev.Properties) + case "message.part.delta": + out = d.partDelta(ev.Properties) + case "message.updated": + out = d.messageUpdated(ev.Properties) + case "permission.asked": + 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"` + } + if json.Unmarshal(ev.Properties, &p) == nil && p.File != "" { + out = []Event{{Kind: EventFileEdited, File: p.File}} + } + case "session.idle": + var p struct { + SessionID string `json:"sessionID"` + } + if json.Unmarshal(ev.Properties, &p) == nil && p.SessionID == d.sessionID { + out = []Event{{Kind: EventDone}} + } + case "session.error": + out = d.sessionError(ev.Properties) + } + for _, e := range out { + if e.Kind == EventDone || e.Kind == EventError { + d.finished = true + } + } + return out +} + +type part struct { + ID string `json:"id"` + SessionID string `json:"sessionID"` + Type string `json:"type"` + CallID string `json:"callID"` + Tool string `json:"tool"` + State struct { + Status string `json:"status"` + Title string `json:"title"` + } `json:"state"` +} + +func (d *decoder) partUpdated(props json.RawMessage) []Event { + var p struct { + Part part `json:"part"` + // Delta is set by OpenCode versions that send text changes on the part + // update and not as a separate delta event. + Delta string `json:"delta"` + } + if json.Unmarshal(props, &p) != nil || p.Part.SessionID != d.sessionID { + return nil + } + d.partTypes[p.Part.ID] = p.Part.Type + switch p.Part.Type { + case "text": + if p.Delta != "" { + return []Event{{Kind: EventText, Text: p.Delta}} + } + case "tool": + status := p.Part.State.Status + // A pending call has no input yet, so there is nothing to show. + if status == "pending" || status == "" || d.toolStatus[p.Part.CallID] == status { + return nil + } + d.toolStatus[p.Part.CallID] = status + return []Event{{Kind: EventTool, Tool: ToolCall{ + ID: p.Part.CallID, + Name: p.Part.Tool, + Status: status, + Title: p.Part.State.Title, + }}} + } + return nil +} + +func (d *decoder) partDelta(props json.RawMessage) []Event { + var p struct { + SessionID string `json:"sessionID"` + PartID string `json:"partID"` + Field string `json:"field"` + Delta string `json:"delta"` + } + if json.Unmarshal(props, &p) != nil || p.SessionID != d.sessionID { + return nil + } + // Show only the reply text, not the model's reasoning. + if p.Field != "text" || d.partTypes[p.PartID] != "text" || p.Delta == "" { + return nil + } + return []Event{{Kind: EventText, Text: p.Delta}} +} + +func (d *decoder) messageUpdated(props json.RawMessage) []Event { + var p struct { + Info struct { + ID string `json:"id"` + SessionID string `json:"sessionID"` + Role string `json:"role"` + ProviderID string `json:"providerID"` + ModelID string `json:"modelID"` + Cost float64 `json:"cost"` + Time struct { + Completed float64 `json:"completed"` + } `json:"time"` + Tokens struct { + Input int `json:"input"` + Output int `json:"output"` + } `json:"tokens"` + } `json:"info"` + } + if json.Unmarshal(props, &p) != nil { + return nil + } + info := p.Info + if info.SessionID != d.sessionID || info.Role != "assistant" || info.Time.Completed == 0 || d.usageSent[info.ID] { + return nil + } + d.usageSent[info.ID] = true + return []Event{{Kind: EventUsage, Usage: Usage{ + ProviderID: info.ProviderID, + ModelID: info.ModelID, + InputTokens: info.Tokens.Input, + OutputTokens: info.Tokens.Output, + Cost: info.Cost, + }}} +} + +func (d *decoder) permissionAsked(props json.RawMessage) []Event { + var p struct { + ID string `json:"id"` + SessionID string `json:"sessionID"` + Permission string `json:"permission"` + Patterns []string `json:"patterns"` + } + if json.Unmarshal(props, &p) != nil || p.SessionID != d.sessionID { + return nil + } + return []Event{{Kind: EventPermission, Permission: PermissionRequest{ + ID: p.ID, + SessionID: p.SessionID, + Permission: p.Permission, + Patterns: p.Patterns, + }}} +} + +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 { + var p struct { + ID string `json:"id"` + SessionID string `json:"sessionID"` + Type string `json:"type"` + Pattern json.RawMessage `json:"pattern"` + Title string `json:"title"` + } + if json.Unmarshal(props, &p) != nil || p.SessionID != d.sessionID { + return nil + } + var patterns []string + var one string + if json.Unmarshal(p.Pattern, &one) == nil && one != "" { + patterns = []string{one} + } else { + _ = json.Unmarshal(p.Pattern, &patterns) + } + if len(patterns) == 0 && p.Title != "" { + patterns = []string{p.Title} + } + return []Event{{Kind: EventPermission, Permission: PermissionRequest{ + ID: p.ID, + SessionID: p.SessionID, + Permission: p.Type, + Patterns: patterns, + legacy: true, + }}} +} + +func (d *decoder) sessionError(props json.RawMessage) []Event { + var p struct { + SessionID string `json:"sessionID"` + Error struct { + Name string `json:"name"` + Data struct { + Message string `json:"message"` + } `json:"data"` + } `json:"error"` + } + if json.Unmarshal(props, &p) != nil { + return nil + } + // An error without a session ID can come from any session, but on a server + // that ldcli started for one run, it belongs to this run. + if p.SessionID != "" && p.SessionID != d.sessionID { + return nil + } + msg := p.Error.Data.Message + if msg == "" { + msg = p.Error.Name + } + if msg == "" { + msg = "OpenCode reported an error." + } + return []Event{{Kind: EventError, Text: msg}} +} + +// readSSE reads a server-sent event stream and calls fn with the data of each +// event. It stops when fn returns false or the stream ends. +func readSSE(r io.Reader, fn func(data []byte) bool) error { + scanner := bufio.NewScanner(r) + // A part update can carry a whole file diff, which is longer than the + // scanner's default line limit. + scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) + var data bytes.Buffer + for scanner.Scan() { + line := scanner.Text() + if line == "" { + if data.Len() > 0 { + if !fn(data.Bytes()) { + return nil + } + data.Reset() + } + continue + } + value, ok := strings.CutPrefix(line, "data:") + if !ok { + continue // event names, IDs, and comments carry nothing that we use + } + if data.Len() > 0 { + data.WriteByte('\n') + } + data.WriteString(strings.TrimPrefix(value, " ")) + } + if err := scanner.Err(); err != nil { + return err + } + if data.Len() > 0 { + fn(data.Bytes()) + } + return nil +} diff --git a/internal/setup/agent/opencode.go b/internal/setup/agent/opencode.go new file mode 100644 index 000000000..1c89bf29f --- /dev/null +++ b/internal/setup/agent/opencode.go @@ -0,0 +1,250 @@ +package agent + +import ( + "context" + "errors" + "fmt" + "io" + "os/exec" + "regexp" + "strconv" + "strings" + "sync" + "time" +) + +// MinOpenCodeVersion is the oldest OpenCode version that the adapter was +// tested with. +const MinOpenCodeVersion = "1.19.0" + +// OpenCode is the Runner that uses the OpenCode CLI. +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 ConfigSource + + // lookPath and version are fields so that tests can replace them. + lookPath func(string) (string, error) + version func(ctx context.Context, binary string) (string, error) +} + +// NewOpenCode returns a Runner that uses OpenCode from the PATH with the +// built-in configuration. +func NewOpenCode() *OpenCode { + return &OpenCode{Binary: "opencode", Config: DefaultConfigSource{}} +} + +// ErrNotInstalled reports that OpenCode is not on the PATH. +var ErrNotInstalled = errors.New("OpenCode is not installed. Install it from https://opencode.ai, then run setup again") + +func (o *OpenCode) binary() string { + if o.Binary == "" { + return "opencode" + } + return o.Binary +} + +func (o *OpenCode) Check(ctx context.Context) error { + lookPath := o.lookPath + if lookPath == nil { + lookPath = exec.LookPath + } + path, err := lookPath(o.binary()) + if err != nil { + return ErrNotInstalled + } + version := o.version + if version == nil { + version = binaryVersion + } + v, err := version(ctx, path) + if err != nil { + return fmt.Errorf("could not read the OpenCode version: %w", err) + } + if !versionAtLeast(v, MinOpenCodeVersion) { + return fmt.Errorf("OpenCode %s is too old. Setup needs version %s or later. Run `opencode upgrade`, then run setup again", v, MinOpenCodeVersion) + } + return nil +} + +func (o *OpenCode) Start(ctx context.Context, task Task) (Run, error) { + source := o.Config + if source == nil { + source = DefaultConfigSource{} + } + cfg, err := source.Resolve(ctx, task) + if err != nil { + return nil, fmt.Errorf("could not get the setup agent configuration: %w", err) + } + 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 := startRun(ctx, srv.client, cfg, BuildPrompt(task)) + if err != nil { + srv.stop() + return nil, err + } + run.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: + } + }() + return run, nil +} + +// openCodeRun is a Run on an OpenCode server. +type openCodeRun struct { + client *client + sessionID string + events chan Event + cancel context.CancelFunc + closed chan struct{} + stopServer func() + + mu sync.Mutex + finished bool + 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) { + sessionID, err := c.createSession(ctx) + if err != nil { + return nil, fmt.Errorf("could not create an OpenCode session: %w", err) + } + streamCtx, cancel := context.WithCancel(context.Background()) + // Subscribe before the prompt goes out, so no event of the run is missed. + stream, err := c.subscribe(streamCtx) + if err != nil { + cancel() + return nil, fmt.Errorf("could not read OpenCode events: %w", err) + } + r := &openCodeRun{ + client: c, + sessionID: sessionID, + events: make(chan Event, 64), + cancel: cancel, + closed: make(chan struct{}), + } + go r.pump(stream) + + 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) + } + return r, nil +} + +func (r *openCodeRun) pump(stream io.ReadCloser) { + defer stream.Close() + dec := newDecoder(r.sessionID) + err := readSSE(stream, func(data []byte) bool { + for _, ev := range dec.decode(data) { + if !r.send(ev) { + return false + } + } + return !dec.finished + }) + if dec.finished { + return + } + select { + case <-r.closed: + return + default: + } + msg := "The OpenCode event stream ended before the run finished." + if err != nil { + msg = fmt.Sprintf("Could not read OpenCode events: %v", err) + } + r.fail(msg) +} + +// send delivers an event, and closes the channel after the last event. It +// returns false if the run is already finished or closed. +func (r *openCodeRun) send(ev Event) bool { + r.mu.Lock() + defer r.mu.Unlock() + if r.finished { + return false + } + select { + case r.events <- ev: + case <-r.closed: + return false + } + if ev.Kind == EventDone || ev.Kind == EventError { + r.finished = true + close(r.events) + } + return true +} + +func (r *openCodeRun) fail(msg string) { + r.send(Event{Kind: EventError, Text: msg}) +} + +func (r *openCodeRun) Events() <-chan Event { return r.events } + +func (r *openCodeRun) Reply(ctx context.Context, req PermissionRequest, allow bool) error { + return r.client.replyPermission(ctx, req, allow) +} + +func (r *openCodeRun) Answer(ctx context.Context, req QuestionRequest, answers [][]string) error { + return r.client.answerQuestion(ctx, req, answers) +} + +func (r *openCodeRun) Close() error { + r.closeOnce.Do(func() { + close(r.closed) + r.cancel() + if r.stopServer != nil { + r.stopServer() + } + }) + return nil +} + +// binaryVersion runs `opencode --version`. +func binaryVersion(ctx context.Context, binary string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, binary, "--version").Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +var versionPattern = regexp.MustCompile(`(\d+)\.(\d+)\.(\d+)`) + +// versionAtLeast reports whether the first version in v is the same as or +// newer than min. It returns false if v has no version number. +func versionAtLeast(v, min string) bool { + got := versionPattern.FindStringSubmatch(v) + want := versionPattern.FindStringSubmatch(min) + if got == nil || want == nil { + return false + } + for i := 1; i <= 3; i++ { + g, _ := strconv.Atoi(got[i]) + w, _ := strconv.Atoi(want[i]) + if g != w { + return g > w + } + } + return true +} diff --git a/internal/setup/agent/opencode_config.go b/internal/setup/agent/opencode_config.go new file mode 100644 index 000000000..70d348855 --- /dev/null +++ b/internal/setup/agent/opencode_config.go @@ -0,0 +1,100 @@ +package agent + +import ( + "encoding/json" + "strings" +) + +// openCodeConfig returns the OpenCode configuration for a run. The CLI passes +// it in OPENCODE_CONFIG_CONTENT, which overrides the user's own OpenCode +// configuration for this process only. +// +// 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]any{ + "read": "allow", + "glob": "allow", + "grep": "allow", + "edit": "allow", + "bash": bash, + "question": "allow", + "external_directory": "deny", + "webfetch": "deny", + "websearch": "deny", + "task": "deny", + }, + }) +} + +// 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. +func childEnv(parent []string, set map[string]string) []string { + env := make([]string, 0, len(parent)+len(set)) + for _, kv := range parent { + name, _, _ := strings.Cut(kv, "=") + if strings.HasPrefix(strings.ToUpper(name), "LD_") { + continue + } + if _, ok := set[name]; ok { + continue + } + env = append(env, kv) + } + for name, value := range set { + env = append(env, name+"="+value) + } + return env +} diff --git a/internal/setup/agent/prompt.go b/internal/setup/agent/prompt.go new file mode 100644 index 000000000..6dab20696 --- /dev/null +++ b/internal/setup/agent/prompt.go @@ -0,0 +1,79 @@ +package agent + +import ( + "fmt" + "strings" +) + +// Credential environment variables. The agent writes code that reads the +// credential from one of these, so the credential never goes into a prompt or +// into the user's source files. +const ( + SDKKeyEnvVar = "LAUNCHDARKLY_SDK_KEY" + ClientSideIDEnvVar = "LAUNCHDARKLY_CLIENT_SIDE_ID" + 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. + +Follow these rules: +- Change only files inside the project directory. +- Make the smallest change that initializes the SDK where the application starts, and evaluates the given flag once. +- Read the SDK credential from an environment variable. Never write a key, an ID, or a token into a file. +- Do not install packages unless the task says that the SDK is not installed. Do not add other dependencies. +- Do not commit, push, or change git configuration. +- 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.` + +// BuildPrompt returns the task message for one run. +func BuildPrompt(t Task) string { + var b strings.Builder + fmt.Fprintf(&b, "Set up the LaunchDarkly SDK %q in this project.\n\n", t.SDKID) + + b.WriteString("Facts that ldcli detected:\n") + writeFact(&b, "Language", t.Language) + writeFact(&b, "Framework", t.Framework) + writeFact(&b, "Package manager", t.PackageManager) + writeFact(&b, "Entry point", t.EntryPoint) + b.WriteString("\n") + + if t.SDKInstalled { + b.WriteString("The SDK package is already installed. Do not install it again.\n") + } else { + 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 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") + } + return b.String() +} + +func writeFact(b *strings.Builder, name, value string) { + if value == "" { + value = "unknown" + } + fmt.Fprintf(b, "- %s: %s\n", name, value) +} diff --git a/internal/setup/agent/sandbox.go b/internal/setup/agent/sandbox.go new file mode 100644 index 000000000..58a4af588 --- /dev/null +++ b/internal/setup/agent/sandbox.go @@ -0,0 +1,230 @@ +package agent + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +// The setup agent runs commands without approvals, and OpenCode's rules limit +// only its file tools, not the paths that a shell command touches. So on +// macOS, ldcli starts OpenCode in a Seatbelt sandbox (sandbox-exec), which +// applies to OpenCode and to every command that it runs. The sandbox denies +// the user's home folder, except the project, OpenCode's own folders, package +// caches, and toolchains. Other repos, ~/.config/ldcli, and ~/.ssh stay +// unreadable. System folders and the network stay open, because the model API +// and the package registries need them. + +const sandboxExec = "/usr/bin/sandbox-exec" + +// sandboxPaths are the folders under the home folder that the sandbox opens. +type sandboxPaths struct { + Home string + // ReadWrite are folders that the agent and its commands can change. + ReadWrite []string + // ReadOnly are folders that commands must read but not change. + ReadOnly []string +} + +// readWriteUnderHome are package caches and OpenCode's own folders, relative to +// the home folder. A package install writes to its cache, and OpenCode writes +// its sessions and snapshots. +var readWriteUnderHome = []string{ + ".local/share/opencode", + ".local/state/opencode", + ".cache/opencode", + ".cache/uv", + ".cache/pip", + ".cache/pypoetry", + ".cache/go-build", + ".npm", + ".bun/install/cache", + ".gradle", + ".m2", + ".cargo/registry", + ".cargo/git", + "go/pkg/mod", + "Library/Caches/pip", + "Library/Caches/pypoetry", + "Library/Caches/go-build", + "Library/Caches/Yarn", + "Library/Caches/pnpm", + "Library/pnpm", +} + +// readOnlyUnderHome are toolchains and settings that commands read. +var readOnlyUnderHome = []string{ + ".config/opencode", + ".local/bin", + ".local/share/uv", + ".local/share/mise", + ".pyenv", + ".rbenv", + ".asdf", + ".nvm", + ".volta", + ".bun/bin", + ".deno", + ".rustup", + ".cargo/bin", + "go/bin", + ".sdkman", + ".gitconfig", + ".config/git", +} + +// buildSandboxPaths returns the folders to open for a run in dir. opencodeDir +// is the folder that holds the OpenCode program, so a copy installed under +// the home folder can still start. +func buildSandboxPaths(home, dir, opencodeDir string, env []string) sandboxPaths { + p := sandboxPaths{Home: home, ReadWrite: []string{dir}} + for _, rel := range readWriteUnderHome { + p.ReadWrite = append(p.ReadWrite, filepath.Join(home, rel)) + } + for _, rel := range readOnlyUnderHome { + p.ReadOnly = append(p.ReadOnly, filepath.Join(home, rel)) + } + // OpenCode follows the XDG folder variables. If they point elsewhere, + // open only OpenCode's folder in each. + for name, mode := range map[string]string{"XDG_DATA_HOME": "rw", "XDG_STATE_HOME": "rw", "XDG_CACHE_HOME": "rw", "XDG_CONFIG_HOME": "ro"} { + value := envValue(env, name) + if value == "" { + continue + } + folder := filepath.Join(value, "opencode") + if mode == "rw" { + p.ReadWrite = append(p.ReadWrite, folder) + } else { + p.ReadOnly = append(p.ReadOnly, folder) + } + } + if opencodeDir != "" { + p.ReadOnly = append(p.ReadOnly, opencodeDir) + } + return p +} + +// seatbeltProfile returns the sandbox profile. Later rules take precedence, +// so the allow rules for the open folders come after the deny rule for the +// home folder. +func seatbeltProfile(p sandboxPaths) string { + var b strings.Builder + b.WriteString("(version 1)\n(allow default)\n") + fmt.Fprintf(&b, "(deny file-read* file-write* (subpath %s))\n", sbString(p.Home)) + // Tools read the metadata of the home folder itself, for example to + // resolve a path, without reading what is in it. + fmt.Fprintf(&b, "(allow file-read-metadata (literal %s))\n", sbString(p.Home)) + for _, path := range p.ReadOnly { + fmt.Fprintf(&b, "(allow file-read* (subpath %s))\n", sbString(path)) + } + for _, path := range p.ReadWrite { + fmt.Fprintf(&b, "(allow file-read* file-write* (subpath %s))\n", sbString(path)) + } + return b.String() +} + +// sbString quotes a path for a Seatbelt profile. +func sbString(s string) string { + return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"` +} + +func envValue(env []string, name string) string { + for _, kv := range env { + if k, v, ok := strings.Cut(kv, "="); ok && k == name { + return v + } + } + return "" +} + +// installDir returns the folder to open for the OpenCode program at path. The +// program is often a launcher in the bin folder of its own install, such as +// .../node_modules/opencode-ai/bin, so the folder above bin holds the rest of +// the install. A shared bin folder, such as ~/.local/bin, opens only itself, +// so other programs' data stays closed. +func installDir(home, path string) string { + bin := filepath.Dir(path) + if filepath.Base(bin) != "bin" { + return bin + } + root := filepath.Dir(bin) + shared := map[string]bool{ + home: true, + filepath.Join(home, ".local"): true, + "/usr": true, + "/usr/local": true, + "/opt/homebrew": true, + } + // Never open a folder that holds the home folder, such as / or /Users. + containsHome := root == home || strings.HasPrefix(home, strings.TrimSuffix(root, "/")+"/") + if shared[root] || containsHome { + return bin + } + return root +} + +// realPath resolves the symlinks in path. A path that does not exist yet keeps +// its resolved parent, so a cache folder that a command creates later is +// still covered. +func realPath(path string) string { + if real, err := filepath.EvalSymlinks(path); err == nil { + return real + } + parent := filepath.Dir(path) + if parent == path { + return path + } + return filepath.Join(realPath(parent), filepath.Base(path)) +} + +func mapPaths(paths []string, f func(string) string) []string { + out := make([]string, len(paths)) + for i, p := range paths { + out[i] = f(p) + } + return out +} + +// sandboxCommand returns the program and arguments that run binary with args +// in the sandbox, and a function that removes the profile file. If the +// sandbox is not available, it returns binary and args unchanged. +func sandboxCommand(binary string, args []string, dir string, env []string) (string, []string, func(), error) { + noop := func() {} + if runtime.GOOS != "darwin" { + return binary, args, noop, nil + } + if _, err := os.Stat(sandboxExec); err != nil { + return binary, args, noop, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", nil, noop, fmt.Errorf("could not find the home folder for the agent sandbox: %w", err) + } + opencodeDir := "" + if path, err := exec.LookPath(binary); err == nil { + if real, err := filepath.EvalSymlinks(path); err == nil { + opencodeDir = installDir(home, real) + } + } + // Seatbelt matches the real path, so a folder behind a symlink, such as + // /var/folders on macOS, must be written as the folder it points to. + paths := buildSandboxPaths(realPath(home), realPath(dir), opencodeDir, env) + paths.ReadWrite = mapPaths(paths.ReadWrite, realPath) + paths.ReadOnly = mapPaths(paths.ReadOnly, realPath) + profile := seatbeltProfile(paths) + f, err := os.CreateTemp("", "ldcli-agent-*.sb") + if err != nil { + return "", nil, noop, err + } + if _, err := f.WriteString(profile); err != nil { + f.Close() + os.Remove(f.Name()) + return "", nil, noop, err + } + f.Close() + cleanup := func() { _ = os.Remove(f.Name()) } + return sandboxExec, append([]string{"-f", f.Name(), binary}, args...), cleanup, nil +} diff --git a/internal/setup/agent/sandbox_test.go b/internal/setup/agent/sandbox_test.go new file mode 100644 index 000000000..aa1eb19c8 --- /dev/null +++ b/internal/setup/agent/sandbox_test.go @@ -0,0 +1,108 @@ +package agent + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSeatbeltProfile_DeniesHomeThenOpensFolders(t *testing.T) { + profile := seatbeltProfile(sandboxPaths{ + Home: "/Users/me", + ReadWrite: []string{"/Users/me/app"}, + ReadOnly: []string{`/Users/me/odd "name"`}, + }) + + deny := strings.Index(profile, `(deny file-read* file-write* (subpath "/Users/me"))`) + allow := strings.Index(profile, `(allow file-read* file-write* (subpath "/Users/me/app"))`) + require.NotEqual(t, -1, deny) + require.NotEqual(t, -1, allow) + assert.Less(t, deny, allow, "later rules win, so the allows must follow the deny") + assert.Contains(t, profile, `(allow file-read* (subpath "/Users/me/odd \"name\""))`, "paths are quoted") +} + +func TestBuildSandboxPaths_OpensOnlyOpenCodesXDGFolders(t *testing.T) { + p := buildSandboxPaths("/Users/me", "/Users/me/app", "", []string{"XDG_CONFIG_HOME=/Users/me/.ldcli-local"}) + + assert.Contains(t, p.ReadWrite, "/Users/me/app") + assert.Contains(t, p.ReadWrite, "/Users/me/.local/share/opencode") + assert.Contains(t, p.ReadOnly, "/Users/me/.ldcli-local/opencode") + assert.NotContains(t, p.ReadOnly, "/Users/me/.ldcli-local", "the ldcli configuration next to it stays closed") + for _, path := range append(p.ReadOnly, p.ReadWrite...) { + assert.NotEqual(t, "/Users/me/.config", path) + assert.NotEqual(t, "/Users/me/.config/ldcli", path) + } +} + +func TestInstallDir(t *testing.T) { + assert.Equal(t, "/opt/homebrew/lib/node_modules/opencode-ai", installDir("/Users/me", "/opt/homebrew/lib/node_modules/opencode-ai/bin/opencode.exe")) + assert.Equal(t, "/Users/me/.opencode", installDir("/Users/me", "/Users/me/.opencode/bin/opencode")) + assert.Equal(t, "/Users/me/.local/bin", installDir("/Users/me", "/Users/me/.local/bin/opencode"), "a shared bin folder opens only itself") + assert.Equal(t, "/usr/local/bin", installDir("/Users/me", "/usr/local/bin/opencode")) + assert.Equal(t, "/bin", installDir("/Users/me", "/bin/opencode"), "never open / or another folder that holds the home folder") + assert.Equal(t, "/Users/bin", installDir("/Users/me", "/Users/bin/opencode")) +} + +func TestOpenCodeConfig_DeniesLDCLIAndSkillsInstalls(t *testing.T) { + data, err := openCodeConfig() + require.NoError(t, err) + + assert.Contains(t, string(data), `"ldcli*":"deny"`) + assert.Contains(t, string(data), `"npx skills*":"deny"`) +} + +// TestSandboxCommand_ConfinesReadsToTheProject runs a real sandboxed command on +// macOS, with a fake home folder. +func TestSandboxCommand_ConfinesReadsToTheProject(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("the sandbox is macOS only") + } + if _, err := os.Stat(sandboxExec); err != nil { + t.Skip("sandbox-exec is not available") + } + home := t.TempDir() + t.Setenv("HOME", home) + project := filepath.Join(home, "code", "app") + write := func(path, content string) string { + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + return path + } + inside := write(filepath.Join(project, "main.py"), "inside") + secrets := []string{ + write(filepath.Join(home, ".config", "ldcli", "config.yml"), "access-token: api-secret"), + write(filepath.Join(home, ".ssh", "id_ed25519"), "key"), + write(filepath.Join(home, "code", "other-repo", "main.py"), "other"), + } + + run := func(path string) (string, error) { + name, args, cleanup, err := sandboxCommand("/bin/cat", []string{path}, project, os.Environ()) + require.NoError(t, err) + defer cleanup() + require.Equal(t, sandboxExec, name) + out, err := exec.Command(name, args...).CombinedOutput() + return string(out), err + } + + out, err := run(inside) + require.NoError(t, err, out) + assert.Equal(t, "inside", out) + for _, path := range secrets { + out, err := run(path) + assert.Error(t, err, "read %s", path) + assert.Contains(t, out, "Operation not permitted", path) + } + + name, args, cleanup, err := sandboxCommand("/usr/bin/touch", []string{filepath.Join(project, "new.txt")}, project, os.Environ()) + require.NoError(t, err) + defer cleanup() + out2, err := exec.Command(name, args...).CombinedOutput() + require.NoError(t, err, string(out2)) + assert.FileExists(t, filepath.Join(project, "new.txt"), "the project stays writable") +} diff --git a/internal/setup/agent/server.go b/internal/setup/agent/server.go new file mode 100644 index 000000000..66efcba07 --- /dev/null +++ b/internal/setup/agent/server.go @@ -0,0 +1,179 @@ +package agent + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "net" + "net/http" + "os" + "os/exec" + "runtime" + "strconv" + "strings" + "sync" + "time" +) + +const ( + readyTimeout = 30 * time.Second + stopTimeout = 5 * time.Second +) + +// server is an `opencode serve` process that ldcli started for one run. +type server struct { + cmd *exec.Cmd + cancel context.CancelFunc + output *tailBuffer + exited chan struct{} + client *client + + stopOnce sync.Once +} + +// startServer starts OpenCode in dir and waits until its API answers. The +// server listens on the loopback interface only, and it requires a password +// that is random for each run. +func startServer(ctx context.Context, binary, dir string, config []byte) (*server, error) { + port, err := freePort() + if err != nil { + return nil, err + } + password, err := randomHex(32) + if err != nil { + return nil, err + } + + 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 { + if runtime.GOOS == "windows" { + return cmd.Process.Kill() + } + return cmd.Process.Signal(os.Interrupt) + } + cmd.WaitDelay = stopTimeout + out := &tailBuffer{max: 4096} + cmd.Stdout = out + cmd.Stderr = out + + if err := cmd.Start(); err != nil { + cancel() + cleanup() + return nil, fmt.Errorf("could not start OpenCode: %w", err) + } + s := &server{ + cmd: cmd, + cancel: cancel, + output: out, + exited: make(chan struct{}), + client: &client{ + baseURL: "http://127.0.0.1:" + strconv.Itoa(port), + password: password, + http: &http.Client{Timeout: 30 * time.Second}, + }, + } + go func() { + _ = cmd.Wait() + cleanup() + close(s.exited) + }() + + if err := s.waitReady(ctx); err != nil { + s.stop() + return nil, err + } + return s, nil +} + +func (s *server) waitReady(ctx context.Context) error { + deadline := time.NewTimer(readyTimeout) + defer deadline.Stop() + tick := time.NewTicker(100 * time.Millisecond) + defer tick.Stop() + for { + reqCtx, cancel := context.WithTimeout(ctx, time.Second) + err := s.client.do(reqCtx, http.MethodGet, "/session", nil, nil) + cancel() + if err == nil { + return nil + } + var se *statusError + if errors.As(err, &se) && se.code == http.StatusUnauthorized { + return fmt.Errorf("OpenCode did not accept the server password. Upgrade OpenCode and try again") + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-s.exited: + return fmt.Errorf("OpenCode stopped before it was ready: %s", s.output.String()) + case <-deadline.C: + return fmt.Errorf("OpenCode did not start within %s", readyTimeout) + case <-tick.C: + } + } +} + +// stop ends the OpenCode process and waits for it to exit. +func (s *server) stop() { + s.stopOnce.Do(func() { + s.cancel() + <-s.exited + }) +} + +func freePort() (int, error) { + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return 0, fmt.Errorf("could not find a free port for OpenCode: %w", err) + } + defer l.Close() + return l.Addr().(*net.TCPAddr).Port, nil +} + +func randomHex(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +// tailBuffer keeps the last max bytes written to it, so an error can show what +// OpenCode printed before it stopped. +type tailBuffer struct { + mu sync.Mutex + max int + buf []byte +} + +func (t *tailBuffer) Write(p []byte) (int, error) { + t.mu.Lock() + defer t.mu.Unlock() + t.buf = append(t.buf, p...) + if len(t.buf) > t.max { + t.buf = t.buf[len(t.buf)-t.max:] + } + return len(p), nil +} + +func (t *tailBuffer) String() string { + t.mu.Lock() + defer t.mu.Unlock() + return strings.TrimSpace(string(t.buf)) +}