From de182b299c3651eb19e76614944e272dd16325b3 Mon Sep 17 00:00:00 2001 From: Anthony Torns II Date: Fri, 16 Oct 2026 16:26:12 -0700 Subject: [PATCH 1/7] feat(setup): add a setup agent step that uses OpenCode Setup can now use an OpenCode session to add the SDK initialization code. The agent step replaces the init step in the wizard. The new `ldcli setup agent` command runs only that step. The `--agent` flag controls the step. With auto, the default, setup uses the agent if OpenCode is installed, and the templates if not. With on, setup stops if the agent cannot run. With off, setup always uses the templates. The CLI starts `opencode serve` on the loopback interface with a random password for each run. It removes every LD_ variable from the OpenCode environment, so OpenCode never gets the access token. The agent can edit files in the project, but it must ask before it runs a shell command. The wizard shows each request as a y or n prompt. The code that the agent writes reads the SDK credential from an environment variable. The CLI shows the export command after the run. The model and the instructions come from a ConfigSource. A built-in source supplies them until the Go AI SDK has an OpenCode handler. Co-Authored-By: Claude Opus 5.5 --- cmd/setup/agent.go | 241 +++++++++++++++++ cmd/setup/agent_test.go | 329 +++++++++++++++++++++++ cmd/setup/commands.go | 46 ++++ cmd/setup/model.go | 75 +++++- cmd/setup/setup.go | 11 +- cmd/setup/update.go | 97 +++++++ cmd/setup/view.go | 79 +++++- internal/setup/agent/agent.go | 136 ++++++++++ internal/setup/agent/agent_test.go | 340 ++++++++++++++++++++++++ internal/setup/agent/client.go | 137 ++++++++++ internal/setup/agent/events.go | 298 +++++++++++++++++++++ internal/setup/agent/opencode.go | 246 +++++++++++++++++ internal/setup/agent/opencode_config.go | 51 ++++ internal/setup/agent/prompt.go | 61 +++++ internal/setup/agent/server.go | 171 ++++++++++++ 15 files changed, 2314 insertions(+), 4 deletions(-) create mode 100644 cmd/setup/agent.go create mode 100644 cmd/setup/agent_test.go create mode 100644 internal/setup/agent/agent.go create mode 100644 internal/setup/agent/agent_test.go create mode 100644 internal/setup/agent/client.go create mode 100644 internal/setup/agent/events.go create mode 100644 internal/setup/agent/opencode.go create mode 100644 internal/setup/agent/opencode_config.go create mode 100644 internal/setup/agent/prompt.go create mode 100644 internal/setup/agent/server.go diff --git a/cmd/setup/agent.go b/cmd/setup/agent.go new file mode 100644 index 000000000..3faeec144 --- /dev/null +++ b/cmd/setup/agent.go @@ -0,0 +1,241 @@ +package setup + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "os/signal" + "path/filepath" + "sort" + "strings" + + "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 +} + +// credentialExports returns the shell commands that set the credentials the +// agent's code reads. The agent never sees the values, so the user must set +// them before the application starts. +func credentialExports(sdkKey, clientSideID, mobileKey string) []string { + var lines []string + add := func(name, value string) { + if value != "" { + lines = append(lines, fmt.Sprintf("export %s=%s", name, value)) + } + } + add(agent.SDKKeyEnvVar, sdkKey) + add(agent.ClientSideIDEnvVar, clientSideID) + add(agent.MobileKeyEnvVar, mobileKey) + return lines +} + +// 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("OpenCode asks to use %s.", req.Permission) + } + return fmt.Sprintf("OpenCode 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 asks before it runs a shell command. 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)) + + ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt) + 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 exports := credentialExports(getFlag(cmd, sdkKeyFlag), getFlag(cmd, clientIDFlag), getFlag(cmd, mobileFlag)); len(exports) > 0 { + fmt.Fprintln(out) + fmt.Fprintln(out, "Set the credential before you start your application:") + for _, e := range exports { + fmt.Fprintf(out, " %s\n", e) + } + } + 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 OpenCode: %w", err) + } + case agent.EventDone: + return sortedKeys(changed), nil + case agent.EventError: + return nil, errors.NewError("The setup agent failed: " + ev.Text) + } + } +} + +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..f0e56b7c8 --- /dev/null +++ b/cmd/setup/agent_test.go @@ -0,0 +1,329 @@ +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 fakeRun struct { + events chan agent.Event + + mu sync.Mutex + replies []fakeReply + 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) 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 OpenCode") +} + +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(), "OpenCode asks to use bash: npm test") + + m, cmd := update(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")}) + require.NotNil(t, cmd) + m, _ = update(t, m, cmd()) + + assert.Nil(t, m.agentPermission) + assert.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") + + 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 OpenCode 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(), "OpenCode 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 TestCredentialExports_SkipsEmptyValues(t *testing.T) { + assert.Equal(t, []string{"export LAUNCHDARKLY_CLIENT_SIDE_ID=abc"}, credentialExports("", "abc", "")) +} diff --git a/cmd/setup/commands.go b/cmd/setup/commands.go index 85bd479e7..d069b8afb 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,47 @@ 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 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..bc324cf1c 100644 --- a/cmd/setup/model.go +++ b/cmd/setup/model.go @@ -2,6 +2,7 @@ package setup import ( "io" + "sync" "github.com/atotto/clipboard" "github.com/charmbracelet/bubbles/list" @@ -14,6 +15,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 +41,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 +104,24 @@ 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 + // agentPermission is the request that waits for the user's answer. + agentPermission *agent.PermissionRequest + 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 +211,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 +254,49 @@ func runSetupWizard( clipboard: terminalWriter(), nativeCopy: clipboard.WriteAll, remoteSession: isRemoteSession(), + agentMode: mode, + agentRunner: runner, + agentRuns: &runTracker{}, } p := tea.NewProgram(m, tea.WithAltScreen()) - _, err := p.Run() + _, 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..30ab082fb 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,14 @@ 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 && m.agentPermission != nil && m.agentRun != nil { + req := *m.agentPermission + m.agentPermission = nil + return m, replyAgent(m.agentRun, req, msg.String() == "y") + } case "left", "h": if m.isFiltering() { break // let the list receive the key as filter input @@ -143,9 +154,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 +248,72 @@ 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.agentPermission = &req + 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.agentPermission = 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() +} + +// 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..bb5e263d6 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 before it runs a command)" + if m.agentMode == agentAuto { + note = "(asks before it runs a command · uses the template if OpenCode 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,57 @@ 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 m.agentPermission != nil { + question := box(true, m.sdkBoxWidth()).Render( + wrapText(describePermission(*m.agentPermission), m.sdkBoxWidth()-4) + "\n" + + mutedStyle.Render("y allow once · n reject")) + return body + question + "\n" + } + status := " OpenCode is working..." + if m.agentRun == nil { + status = " Starting OpenCode..." + } + 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" + } + if exports := credentialExports(m.sdkKey, m.clientSideID, m.mobileKey); len(exports) > 0 { + body += m.wrap("Set the credential that your SDK uses before you start the application:") + "\n\n" + + code(strings.Join(exports, "\n")) + "\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..6a94c6a57 --- /dev/null +++ b/internal/setup/agent/agent.go @@ -0,0 +1,136 @@ +// 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 + // 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 + 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 +} + +// 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 + // 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..cf027fec4 --- /dev/null +++ b/internal/setup/agent/agent_test.go @@ -0,0 +1,340 @@ +package agent + +import ( + "context" + "encoding/json" + "fmt" + "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, SDKKeyEnvVar) + assert.NotContains(t, prompt, "did not find an entry point") +} + +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_AsksBeforeShellCommands(t *testing.T) { + data, err := openCodeConfig() + require.NoError(t, err) + + var cfg struct { + Permission map[string]string `json:"permission"` + } + require.NoError(t, json.Unmarshal(data, &cfg)) + assert.Equal(t, "ask", cfg.Permission["bash"]) + assert.Equal(t, "allow", cfg.Permission["edit"]) + assert.Equal(t, "deny", cfg.Permission["external_directory"]) + assert.Equal(t, "deny", cfg.Permission["webfetch"]) +} + +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_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 /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_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..42da5619c --- /dev/null +++ b/internal/setup/agent/client.go @@ -0,0 +1,137 @@ +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) +} + +// 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..8dccd847a --- /dev/null +++ b/internal/setup/agent/events.go @@ -0,0 +1,298 @@ +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 "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, + }}} +} + +// 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..ed6616ded --- /dev/null +++ b/internal/setup/agent/opencode.go @@ -0,0 +1,246 @@ +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) 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..c612473ec --- /dev/null +++ b/internal/setup/agent/opencode_config.go @@ -0,0 +1,51 @@ +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 permission rules let OpenCode read and edit files in the project without +// asking. Every shell command needs the user's approval. Rules deny access to +// files outside the project, and they deny web access and sub-agents. +func openCodeConfig() ([]byte, error) { + return json.Marshal(map[string]any{ + "$schema": "https://opencode.ai/config.json", + "permission": map[string]string{ + "read": "allow", + "glob": "allow", + "grep": "allow", + "edit": "allow", + "bash": "ask", + "external_directory": "deny", + "webfetch": "deny", + "websearch": "deny", + "task": "deny", + }, + }) +} + +// 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..ed5b90cf8 --- /dev/null +++ b/internal/setup/agent/prompt.go @@ -0,0 +1,61 @@ +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" +) + +// 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 an environment variable: %s for a server-side SDK, %s for a client-side JavaScript SDK, or %s for a mobile SDK.\n", + SDKKeyEnvVar, ClientSideIDEnvVar, MobileKeyEnvVar) + 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/server.go b/internal/setup/agent/server.go new file mode 100644 index 000000000..9424720a7 --- /dev/null +++ b/internal/setup/agent/server.go @@ -0,0 +1,171 @@ +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 + } + + procCtx, cancel := context.WithCancel(context.Background()) + cmd := exec.CommandContext(procCtx, binary, "serve", "--hostname", "127.0.0.1", "--port", strconv.Itoa(port)) + cmd.Dir = dir + cmd.Env = childEnv(os.Environ(), map[string]string{ + "OPENCODE_SERVER_PASSWORD": password, + "OPENCODE_CONFIG_CONTENT": string(config), + }) + // 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() + 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() + 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)) +} From becba30719f77d3fff01e225cefebb397654cba9 Mon Sep 17 00:00:00 2001 From: Anthony Torns II Date: Mon, 19 Oct 2026 16:36:02 -0700 Subject: [PATCH 2/7] fix(setup): queue the agent's permission requests OpenCode can ask for several tool calls at once. The wizard kept only one pending request, so each new request replaced the one before it. The replaced requests never got an answer, and OpenCode waited forever. The wizard now keeps a queue. It shows the oldest request and how many more are waiting, and each y or n answers one request. Co-Authored-By: Claude Opus 5.5 --- cmd/setup/agent_test.go | 26 +++++++++++++++++++++++++- cmd/setup/model.go | 10 ++++++---- cmd/setup/update.go | 10 +++++----- cmd/setup/view.go | 10 +++++++--- 4 files changed, 43 insertions(+), 13 deletions(-) diff --git a/cmd/setup/agent_test.go b/cmd/setup/agent_test.go index f0e56b7c8..1699d9567 100644 --- a/cmd/setup/agent_test.go +++ b/cmd/setup/agent_test.go @@ -175,7 +175,7 @@ func TestWizard_AgentPermission_YAllows(t *testing.T) { require.NotNil(t, cmd) m, _ = update(t, m, cmd()) - assert.Nil(t, m.agentPermission) + assert.Empty(t, m.agentPermissions) assert.Equal(t, []fakeReply{{id: "per1", allow: true}}, run.replies) } @@ -327,3 +327,27 @@ func TestStreamAgent_Error(t *testing.T) { func TestCredentialExports_SkipsEmptyValues(t *testing.T) { assert.Equal(t, []string{"export LAUNCHDARKLY_CLIENT_SIDE_ID=abc"}, credentialExports("", "abc", "")) } + +// OpenCode can ask for several tool calls at once. Each one must get its own +// answer, or OpenCode waits forever. +func TestWizard_AgentPermissions_AreQueued(t *testing.T) { + run := newFakeRun() + m := startedAgent(t, agentAuto, run) + for _, id := range []string{"per1", "per2", "per3"} { + m, _ = update(t, m, agentEventMsg{run: run, ev: agent.Event{Kind: agent.EventPermission, Permission: agent.PermissionRequest{ID: id, Permission: "bash", Patterns: []string{id}}}}) + } + view := m.View() + assert.Contains(t, view, "bash: per1", "the oldest request is shown first") + assert.Contains(t, view, "2 more waiting") + + for _, key := range []string{"y", "n", "y"} { + var cmd tea.Cmd + m, cmd = update(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}) + require.NotNil(t, cmd) + cmd() + } + + assert.Equal(t, []fakeReply{{id: "per1", allow: true}, {id: "per2", allow: false}, {id: "per3", allow: true}}, run.replies) + assert.Empty(t, m.agentPermissions) + assert.Contains(t, m.View(), "OpenCode is working") +} diff --git a/cmd/setup/model.go b/cmd/setup/model.go index bc324cf1c..6dbd4d41f 100644 --- a/cmd/setup/model.go +++ b/cmd/setup/model.go @@ -114,10 +114,12 @@ type wizardModel struct { agentLog []string // agentFiles are the files that the agent changed. agentFiles []string - // agentPermission is the request that waits for the user's answer. - agentPermission *agent.PermissionRequest - agentUsage *agent.Usage - agentDone bool + // agentPermissions are the requests that wait for the user's answer, oldest + // first. OpenCode can ask for several tool calls at once, and each one + // waits until it gets its own answer. + agentPermissions []agent.PermissionRequest + agentUsage *agent.Usage + agentDone bool // agentNote tells the user why setup used the templates after the agent // could not run. agentNote string diff --git a/cmd/setup/update.go b/cmd/setup/update.go index 30ab082fb..223fccc7b 100644 --- a/cmd/setup/update.go +++ b/cmd/setup/update.go @@ -51,9 +51,9 @@ func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.closeAgent() return m, tea.Quit case "y", "n": - if m.step == stepAgent && m.agentPermission != nil && m.agentRun != nil { - req := *m.agentPermission - m.agentPermission = nil + if m.step == stepAgent && len(m.agentPermissions) > 0 && m.agentRun != nil { + req := m.agentPermissions[0] + m.agentPermissions = m.agentPermissions[1:] return m, replyAgent(m.agentRun, req, msg.String() == "y") } case "left", "h": @@ -273,7 +273,7 @@ func (m wizardModel) handleAgentEvent(msg agentEventMsg) (tea.Model, tea.Cmd) { m.agentUsage = &u case agent.EventPermission: req := ev.Permission - m.agentPermission = &req + m.agentPermissions = append(m.agentPermissions, req) case agent.EventDone: m.agentDone = true m.closeAgent() @@ -296,7 +296,7 @@ func (m wizardModel) handleAgentEvent(msg agentEventMsg) (tea.Model, tea.Cmd) { func (m wizardModel) agentFailed(err error) (tea.Model, tea.Cmd) { m.closeAgent() m.agentRun = nil - m.agentPermission = nil + m.agentPermissions = nil if m.agentMode == agentOn || len(m.agentFiles) > 0 { m.err = errors.New("The setup agent failed: " + err.Error()) return m, nil diff --git a/cmd/setup/view.go b/cmd/setup/view.go index bb5e263d6..538d45157 100644 --- a/cmd/setup/view.go +++ b/cmd/setup/view.go @@ -430,10 +430,14 @@ func (m wizardModel) agentView() string { if len(log) > 0 { body += "\n" } - if m.agentPermission != nil { + if len(m.agentPermissions) > 0 { + hint := "y allow once · n reject" + if waiting := len(m.agentPermissions) - 1; waiting > 0 { + hint += fmt.Sprintf(" · %d more waiting", waiting) + } question := box(true, m.sdkBoxWidth()).Render( - wrapText(describePermission(*m.agentPermission), m.sdkBoxWidth()-4) + "\n" + - mutedStyle.Render("y allow once · n reject")) + wrapText(describePermission(m.agentPermissions[0]), m.sdkBoxWidth()-4) + "\n" + + mutedStyle.Render(hint)) return body + question + "\n" } status := " OpenCode is working..." From 8a01874654e2a9596a22a1742feb096c34a628d1 Mon Sep 17 00:00:00 2001 From: Anthony Torns II Date: Mon, 19 Oct 2026 16:47:31 -0700 Subject: [PATCH 3/7] fix(setup): show only the SDK's credential and say "the agent" The wait-for-app screen printed the SDK key, the client-side ID, and the mobile key, although each SDK reads only one of them. It now shows only the credential that the chosen SDK uses. The prompt also names only that environment variable, so the agent does not have to choose. The wizard now says "the agent" instead of "OpenCode" in its progress text and permission questions. Messages that tell the user to install or upgrade OpenCode keep the name, because the user must act on them. Co-Authored-By: Claude Opus 5.5 --- cmd/setup/agent.go | 40 +++++++++++++++--------------- cmd/setup/agent_test.go | 18 ++++++++------ cmd/setup/view.go | 14 +++++++---- internal/setup/agent/agent_test.go | 9 ++++++- internal/setup/agent/prompt.go | 22 ++++++++++++++-- 5 files changed, 68 insertions(+), 35 deletions(-) diff --git a/cmd/setup/agent.go b/cmd/setup/agent.go index 3faeec144..8d8351cc7 100644 --- a/cmd/setup/agent.go +++ b/cmd/setup/agent.go @@ -60,20 +60,22 @@ func agentTask(dir string, detection *setup.DetectResult, flagKey string, sdkIns return t } -// credentialExports returns the shell commands that set the credentials the -// agent's code reads. The agent never sees the values, so the user must set -// them before the application starts. -func credentialExports(sdkKey, clientSideID, mobileKey string) []string { - var lines []string - add := func(name, value string) { - if value != "" { - lines = append(lines, fmt.Sprintf("export %s=%s", name, value)) - } +// credentialExport returns the shell command that sets the credential that +// the agent's code reads for the SDK, or an empty string if that credential is +// not known. The agent never sees the value, so the user must set it before +// the application starts. Only the SDK's own credential is shown, so the +// screen does not print keys that the application does not use. +func credentialExport(sdkID, sdkKey, clientSideID, mobileKey string) string { + name := agent.CredentialEnvVar(sdkID) + value := map[string]string{ + agent.SDKKeyEnvVar: sdkKey, + agent.ClientSideIDEnvVar: clientSideID, + agent.MobileKeyEnvVar: mobileKey, + }[name] + if value == "" { + return "" } - add(agent.SDKKeyEnvVar, sdkKey) - add(agent.ClientSideIDEnvVar, clientSideID) - add(agent.MobileKeyEnvVar, mobileKey) - return lines + return fmt.Sprintf("export %s=%s", name, value) } // describeTool returns one line for a tool call. @@ -94,9 +96,9 @@ func describeTool(tc agent.ToolCall) string { // describePermission returns the question for a permission request. func describePermission(req agent.PermissionRequest) string { if len(req.Patterns) == 0 { - return fmt.Sprintf("OpenCode asks to use %s.", req.Permission) + return fmt.Sprintf("The agent asks to use %s.", req.Permission) } - return fmt.Sprintf("OpenCode asks to use %s: %s", req.Permission, strings.Join(req.Patterns, ", ")) + return fmt.Sprintf("The agent asks to use %s: %s", req.Permission, strings.Join(req.Patterns, ", ")) } func newAgentCmd(svc setup.Service, runner agent.Runner) *cobra.Command { @@ -176,12 +178,10 @@ func runAgent(svc setup.Service, runner agent.Runner) func(*cobra.Command, []str fmt.Fprintf(out, " %s\n", f) } } - if exports := credentialExports(getFlag(cmd, sdkKeyFlag), getFlag(cmd, clientIDFlag), getFlag(cmd, mobileFlag)); len(exports) > 0 { + if export := credentialExport(sdkID, getFlag(cmd, sdkKeyFlag), getFlag(cmd, clientIDFlag), getFlag(cmd, mobileFlag)); export != "" { fmt.Fprintln(out) fmt.Fprintln(out, "Set the credential before you start your application:") - for _, e := range exports { - fmt.Fprintf(out, " %s\n", e) - } + fmt.Fprintf(out, " %s\n", export) } return nil } @@ -221,7 +221,7 @@ func streamAgent(ctx context.Context, run agent.Run, out io.Writer, in *bufio.Re answer, _ := in.ReadString('\n') allow := strings.EqualFold(strings.TrimSpace(answer), "y") if err := run.Reply(ctx, ev.Permission, allow); err != nil { - return nil, fmt.Errorf("could not answer OpenCode: %w", err) + return nil, fmt.Errorf("could not answer the agent: %w", err) } case agent.EventDone: return sortedKeys(changed), nil diff --git a/cmd/setup/agent_test.go b/cmd/setup/agent_test.go index 1699d9567..ad530752f 100644 --- a/cmd/setup/agent_test.go +++ b/cmd/setup/agent_test.go @@ -128,7 +128,7 @@ func TestWizard_AgentAuto_StartsAgentWithTheTask(t *testing.T) { assert.Equal(t, "index.js", runner.tasks[0].EntryPoint) assert.Equal(t, "my-new-flag", runner.tasks[0].FlagKey) assert.NotEmpty(t, runner.tasks[0].Dir) - assert.Contains(t, m.View(), "Starting OpenCode") + assert.Contains(t, m.View(), "Starting the agent") } func TestWizard_AgentUnavailable_AutoFallsBackToInit(t *testing.T) { @@ -169,7 +169,7 @@ func TestWizard_AgentPermission_YAllows(t *testing.T) { req := agent.PermissionRequest{ID: "per1", Permission: "bash", Patterns: []string{"npm test"}} m, _ = update(t, m, agentEventMsg{run: run, ev: agent.Event{Kind: agent.EventPermission, Permission: req}}) - assert.Contains(t, m.View(), "OpenCode asks to use bash: npm test") + assert.Contains(t, m.View(), "The agent asks to use bash: npm test") m, cmd := update(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")}) require.NotNil(t, cmd) @@ -202,6 +202,7 @@ func TestWizard_AgentDone_GoesToWaitForAppWithExports(t *testing.T) { view := m.View() assert.Contains(t, view, "index.js") assert.Contains(t, view, "export LAUNCHDARKLY_SDK_KEY=sdk-123") + assert.NotContains(t, view, "LAUNCHDARKLY_MOBILE_KEY", "only the SDK's own credential is shown") m, cmd := update(t, m, tea.KeyMsg{Type: tea.KeyEnter}) assert.Equal(t, stepVerify, m.step) @@ -282,7 +283,7 @@ func TestWizard_Plan_AgentMode_SaysAgent(t *testing.T) { view := m.View() assert.Contains(t, view, "Use the setup agent to add initialization code") - assert.Contains(t, view, "uses the template if OpenCode cannot run") + assert.Contains(t, view, "uses the template if the agent cannot run") } func TestStreamAgent_AnswersPermissionAndReturnsFiles(t *testing.T) { @@ -300,7 +301,7 @@ func TestStreamAgent_AnswersPermissionAndReturnsFiles(t *testing.T) { require.NoError(t, err) assert.Equal(t, []string{"a.js", "b.js"}, files) assert.Equal(t, []fakeReply{{id: "per1", allow: true}}, run.replies) - assert.Contains(t, out.String(), "OpenCode asks to use bash: npm test") + assert.Contains(t, out.String(), "The agent asks to use bash: npm test") } func TestStreamAgent_EmptyAnswerRejects(t *testing.T) { @@ -324,8 +325,11 @@ func TestStreamAgent_Error(t *testing.T) { assert.Contains(t, err.Error(), "no API key") } -func TestCredentialExports_SkipsEmptyValues(t *testing.T) { - assert.Equal(t, []string{"export LAUNCHDARKLY_CLIENT_SIDE_ID=abc"}, credentialExports("", "abc", "")) +func TestCredentialExport_ShowsOnlyTheSDKsCredential(t *testing.T) { + assert.Equal(t, "export LAUNCHDARKLY_SDK_KEY=sdk-1", credentialExport("python-server-sdk", "sdk-1", "cid", "mob-1")) + assert.Equal(t, "export LAUNCHDARKLY_CLIENT_SIDE_ID=cid", credentialExport("react-client-sdk", "sdk-1", "cid", "mob-1")) + assert.Equal(t, "export LAUNCHDARKLY_MOBILE_KEY=mob-1", credentialExport("android", "sdk-1", "cid", "mob-1")) + assert.Equal(t, "", credentialExport("node-server", "", "cid", "mob-1"), "no value, no line") } // OpenCode can ask for several tool calls at once. Each one must get its own @@ -349,5 +353,5 @@ func TestWizard_AgentPermissions_AreQueued(t *testing.T) { assert.Equal(t, []fakeReply{{id: "per1", allow: true}, {id: "per2", allow: false}, {id: "per3", allow: true}}, run.replies) assert.Empty(t, m.agentPermissions) - assert.Contains(t, m.View(), "OpenCode is working") + assert.Contains(t, m.View(), "The agent is working") } diff --git a/cmd/setup/view.go b/cmd/setup/view.go index 538d45157..e78272e3e 100644 --- a/cmd/setup/view.go +++ b/cmd/setup/view.go @@ -383,7 +383,7 @@ func (m wizardModel) planView() string { if m.agentMode != agentOff && m.agentRunner != nil { note := "(asks before it runs a command)" if m.agentMode == agentAuto { - note = "(asks before it runs a command · uses the template if OpenCode cannot run)" + note = "(asks before it runs a command · uses the template if the agent cannot run)" } add("Use the setup agent to add initialization code " + mutedStyle.Render(note)) add("Verify the SDK connects to LaunchDarkly") @@ -440,9 +440,9 @@ func (m wizardModel) agentView() string { mutedStyle.Render(hint)) return body + question + "\n" } - status := " OpenCode is working..." + status := " The agent is working..." if m.agentRun == nil { - status = " Starting OpenCode..." + status = " Starting the agent..." } return body + m.spinner.View() + status + "\n\n" + mutedStyle.Render("q quit") } @@ -461,9 +461,13 @@ func (m wizardModel) agentWaitView() string { } body += "\n" } - if exports := credentialExports(m.sdkKey, m.clientSideID, m.mobileKey); len(exports) > 0 { + sdkID := "" + if m.detectResult != nil { + sdkID = m.detectResult.SDKID + } + if export := credentialExport(sdkID, m.sdkKey, m.clientSideID, m.mobileKey); export != "" { body += m.wrap("Set the credential that your SDK uses before you start the application:") + "\n\n" + - code(strings.Join(exports, "\n")) + "\n\n" + code(export) + "\n\n" } if m.agentUsage != nil && m.agentUsage.ModelID != "" { body += mutedStyle.Render(m.wrap(fmt.Sprintf("Model: %s/%s", m.agentUsage.ProviderID, m.agentUsage.ModelID))) + "\n\n" diff --git a/internal/setup/agent/agent_test.go b/internal/setup/agent/agent_test.go index cf027fec4..8af8fe491 100644 --- a/internal/setup/agent/agent_test.go +++ b/internal/setup/agent/agent_test.go @@ -30,10 +30,17 @@ func TestBuildPrompt_NamesTheTaskAndNoCredential(t *testing.T) { assert.Contains(t, prompt, "- Entry point: index.js") assert.Contains(t, prompt, "- Framework: unknown") assert.Contains(t, prompt, "already installed") - assert.Contains(t, prompt, SDKKeyEnvVar) + assert.Contains(t, prompt, "environment variable "+SDKKeyEnvVar) + assert.NotContains(t, prompt, MobileKeyEnvVar, "the prompt names only the SDK's own credential") assert.NotContains(t, prompt, "did not find an entry point") } +func TestCredentialEnvVar(t *testing.T) { + assert.Equal(t, SDKKeyEnvVar, CredentialEnvVar("python-server-sdk")) + assert.Equal(t, ClientSideIDEnvVar, CredentialEnvVar("js-client-sdk")) + assert.Equal(t, MobileKeyEnvVar, CredentialEnvVar("swift-client-sdk")) +} + func TestBuildPrompt_NoEntryPoint_AsksTheAgentToFindOne(t *testing.T) { prompt := BuildPrompt(Task{SDKID: "go-server-sdk", FlagKey: "f"}) diff --git a/internal/setup/agent/prompt.go b/internal/setup/agent/prompt.go index ed5b90cf8..6dab20696 100644 --- a/internal/setup/agent/prompt.go +++ b/internal/setup/agent/prompt.go @@ -14,6 +14,25 @@ const ( MobileKeyEnvVar = "LAUNCHDARKLY_MOBILE_KEY" ) +// clientSideSDKs read the client-side ID, and mobileSDKs read the mobile key. +// Every other SDK reads the server-side SDK key. +var ( + clientSideSDKs = map[string]bool{"react-client-sdk": true, "js-client-sdk": true} + mobileSDKs = map[string]bool{"react-native": true, "android": true, "swift-client-sdk": true} +) + +// CredentialEnvVar returns the environment variable that the agent's code +// must read for the SDK. +func CredentialEnvVar(sdkID string) string { + switch { + case clientSideSDKs[sdkID]: + return ClientSideIDEnvVar + case mobileSDKs[sdkID]: + return MobileKeyEnvVar + } + return SDKKeyEnvVar +} + // DefaultInstructions is the system prompt that the built-in configuration // uses. const DefaultInstructions = `You are the LaunchDarkly setup agent. You add LaunchDarkly SDK initialization code to the project in the current directory. @@ -45,8 +64,7 @@ func BuildPrompt(t Task) string { b.WriteString("The SDK package is not installed. Install it with the project's package manager.\n") } fmt.Fprintf(&b, "Evaluate the boolean flag %q once after the SDK initializes, and print or log its value.\n", t.FlagKey) - fmt.Fprintf(&b, "Read the credential from an environment variable: %s for a server-side SDK, %s for a client-side JavaScript SDK, or %s for a mobile SDK.\n", - SDKKeyEnvVar, ClientSideIDEnvVar, MobileKeyEnvVar) + fmt.Fprintf(&b, "Read the credential from the environment variable %s.\n", CredentialEnvVar(t.SDKID)) if t.EntryPoint == "" { b.WriteString("ldcli did not find an entry point. Find where the application starts before you edit.\n") } From e0e72acaa703fa51173a10a34a55e8d1d3e6c21e Mon Sep 17 00:00:00 2001 From: Anthony Torns II Date: Mon, 19 Oct 2026 17:20:39 -0700 Subject: [PATCH 4/7] feat(setup): show the agent's questions in the wizard The agent can ask the user to choose with OpenCode's question tool, for example where the SDK key lives. The wizard did not show these questions, so the run waited for an answer that never came. The wizard now shows each question with numbered options. A number picks an option, and s skips the request, so the agent goes on without answers. `ldcli setup agent` asks on the terminal the same way. Co-Authored-By: Claude Opus 5.5 --- cmd/setup/agent.go | 43 +++++++++++++++ cmd/setup/agent_test.go | 87 ++++++++++++++++++++++++++++++ cmd/setup/commands.go | 6 +++ cmd/setup/model.go | 9 +++- cmd/setup/update.go | 34 ++++++++++++ cmd/setup/view.go | 18 +++++++ internal/setup/agent/agent.go | 32 +++++++++++ internal/setup/agent/agent_test.go | 35 ++++++++++++ internal/setup/agent/client.go | 10 ++++ internal/setup/agent/events.go | 30 +++++++++++ internal/setup/agent/opencode.go | 4 ++ 11 files changed, 306 insertions(+), 2 deletions(-) diff --git a/cmd/setup/agent.go b/cmd/setup/agent.go index 8d8351cc7..733253d7a 100644 --- a/cmd/setup/agent.go +++ b/cmd/setup/agent.go @@ -9,6 +9,7 @@ import ( "os/signal" "path/filepath" "sort" + "strconv" "strings" "github.com/spf13/cobra" @@ -223,6 +224,14 @@ func streamAgent(ctx context.Context, run agent.Run, out io.Writer, in *bufio.Re if err := run.Reply(ctx, ev.Permission, allow); err != nil { return nil, fmt.Errorf("could not answer the agent: %w", err) } + case agent.EventQuestion: + answers, err := askQuestions(out, in, ev.Question) + if err != nil { + return nil, err + } + if err := run.Answer(ctx, ev.Question, answers); err != nil { + return nil, fmt.Errorf("could not answer the agent: %w", err) + } case agent.EventDone: return sortedKeys(changed), nil case agent.EventError: @@ -231,6 +240,40 @@ func streamAgent(ctx context.Context, run agent.Run, out io.Writer, in *bufio.Re } } +// askQuestions prints each question with numbered options and reads the +// user's choice. An empty answer skips the request, and the agent goes on +// without answers. +func askQuestions(out io.Writer, in *bufio.Reader, req agent.QuestionRequest) ([][]string, error) { + var answers [][]string + for _, q := range req.Questions { + fmt.Fprintf(out, "\nThe agent asks: %s\n", q.Text) + for i, o := range q.Options { + fmt.Fprintf(out, " %d. %s", i+1, o.Label) + if o.Description != "" { + fmt.Fprintf(out, " — %s", o.Description) + } + fmt.Fprintln(out) + } + for { + fmt.Fprintf(out, "Choose 1-%d, or press Enter to skip: ", len(q.Options)) + line, err := in.ReadString('\n') + line = strings.TrimSpace(line) + if line == "" { + return nil, nil + } + choice, convErr := strconv.Atoi(line) + if convErr == nil && choice >= 1 && choice <= len(q.Options) { + answers = append(answers, []string{q.Options[choice-1].Label}) + break + } + if err != nil { + return nil, nil + } + } + } + return answers, nil +} + func sortedKeys(m map[string]bool) []string { keys := make([]string, 0, len(m)) for k := range m { diff --git a/cmd/setup/agent_test.go b/cmd/setup/agent_test.go index ad530752f..900ac4af7 100644 --- a/cmd/setup/agent_test.go +++ b/cmd/setup/agent_test.go @@ -21,11 +21,17 @@ type fakeReply struct { allow bool } +type fakeAnswer struct { + id string + answers [][]string +} + type fakeRun struct { events chan agent.Event mu sync.Mutex replies []fakeReply + answers []fakeAnswer closed int } @@ -46,6 +52,13 @@ func (r *fakeRun) Reply(_ context.Context, req agent.PermissionRequest, allow bo return nil } +func (r *fakeRun) Answer(_ context.Context, req agent.QuestionRequest, answers [][]string) error { + r.mu.Lock() + defer r.mu.Unlock() + r.answers = append(r.answers, fakeAnswer{id: req.ID, answers: answers}) + return nil +} + func (r *fakeRun) Close() error { r.mu.Lock() defer r.mu.Unlock() @@ -355,3 +368,77 @@ func TestWizard_AgentPermissions_AreQueued(t *testing.T) { assert.Empty(t, m.agentPermissions) assert.Contains(t, m.View(), "The agent is working") } + +func secretQuestion() agent.QuestionRequest { + return agent.QuestionRequest{ID: "que1", Questions: []agent.Question{{ + Header: "Secret storage", + Text: "Where should LAUNCHDARKLY_SDK_KEY live?", + Options: []agent.QuestionOption{ + {Label: ".env (Recommended)", Description: "Added to .env."}, + {Label: "Shell profile", Description: "You export it yourself."}, + }, + }}} +} + +func TestWizard_AgentQuestion_NumberAnswers(t *testing.T) { + run := newFakeRun() + m := startedAgent(t, agentAuto, run) + m, _ = update(t, m, agentEventMsg{run: run, ev: agent.Event{Kind: agent.EventQuestion, Question: secretQuestion()}}) + view := m.View() + assert.Contains(t, view, "Where should LAUNCHDARKLY_SDK_KEY live?") + assert.Contains(t, view, "1. .env (Recommended)") + assert.Contains(t, view, "1-2 choose · s skip") + + _, none := update(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("3")}) + assert.Nil(t, none, "a number without an option does nothing") + + m, cmd := update(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("2")}) + require.NotNil(t, cmd) + cmd() + + assert.Equal(t, []fakeAnswer{{id: "que1", answers: [][]string{{"Shell profile"}}}}, run.answers) + assert.Empty(t, m.agentQuestions) +} + +func TestWizard_AgentQuestion_SSkips(t *testing.T) { + run := newFakeRun() + m := startedAgent(t, agentAuto, run) + m, _ = update(t, m, agentEventMsg{run: run, ev: agent.Event{Kind: agent.EventQuestion, Question: secretQuestion()}}) + + _, cmd := update(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("s")}) + require.NotNil(t, cmd) + cmd() + + assert.Equal(t, []fakeAnswer{{id: "que1", answers: nil}}, run.answers) +} + +func TestWizard_AgentQuestion_SeveralQuestionsAnsweredInOrder(t *testing.T) { + run := newFakeRun() + m := startedAgent(t, agentAuto, run) + req := secretQuestion() + req.Questions = append(req.Questions, agent.Question{Header: "Flag", Text: "Log the value?", Options: []agent.QuestionOption{{Label: "Yes"}, {Label: "No"}}}) + m, _ = update(t, m, agentEventMsg{run: run, ev: agent.Event{Kind: agent.EventQuestion, Question: req}}) + + m, cmd := update(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("1")}) + assert.Nil(t, cmd, "the wizard waits for the second answer") + assert.Contains(t, m.View(), "question 2 of 2") + _, cmd = update(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("2")}) + require.NotNil(t, cmd) + cmd() + + assert.Equal(t, []fakeAnswer{{id: "que1", answers: [][]string{{".env (Recommended)"}, {"No"}}}}, run.answers) +} + +func TestStreamAgent_AnswersQuestion(t *testing.T) { + run := newFakeRun( + agent.Event{Kind: agent.EventQuestion, Question: secretQuestion()}, + agent.Event{Kind: agent.EventDone}, + ) + var out bytes.Buffer + + _, err := streamAgent(context.Background(), run, &out, bufio.NewReader(strings.NewReader("9\n1\n"))) + + require.NoError(t, err) + assert.Equal(t, []fakeAnswer{{id: "que1", answers: [][]string{{".env (Recommended)"}}}}, run.answers, "an invalid number asks again") + assert.Contains(t, out.String(), "The agent asks: Where should LAUNCHDARKLY_SDK_KEY live?") +} diff --git a/cmd/setup/commands.go b/cmd/setup/commands.go index d069b8afb..54e298266 100644 --- a/cmd/setup/commands.go +++ b/cmd/setup/commands.go @@ -240,6 +240,12 @@ func waitForAgentEvent(run agent.Run) tea.Cmd { } } +func answerAgent(run agent.Run, req agent.QuestionRequest, answers [][]string) tea.Cmd { + return func() tea.Msg { + return agentRepliedMsg{err: run.Answer(context.Background(), req, answers)} + } +} + func replyAgent(run agent.Run, req agent.PermissionRequest, allow bool) tea.Cmd { return func() tea.Msg { return agentRepliedMsg{err: run.Reply(context.Background(), req, allow)} diff --git a/cmd/setup/model.go b/cmd/setup/model.go index 6dbd4d41f..a22b0c44f 100644 --- a/cmd/setup/model.go +++ b/cmd/setup/model.go @@ -118,8 +118,13 @@ type wizardModel struct { // first. OpenCode can ask for several tool calls at once, and each one // waits until it gets its own answer. agentPermissions []agent.PermissionRequest - agentUsage *agent.Usage - agentDone bool + // agentQuestions are the agent's question requests that wait for answers, + // oldest first. agentAnswers holds the answers to the first request so far, + // one for each of its questions that the user answered. + agentQuestions []agent.QuestionRequest + agentAnswers [][]string + agentUsage *agent.Usage + agentDone bool // agentNote tells the user why setup used the templates after the agent // could not run. agentNote string diff --git a/cmd/setup/update.go b/cmd/setup/update.go index 223fccc7b..0bbc6719e 100644 --- a/cmd/setup/update.go +++ b/cmd/setup/update.go @@ -56,6 +56,10 @@ func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.agentPermissions = m.agentPermissions[1:] return m, replyAgent(m.agentRun, req, msg.String() == "y") } + case "1", "2", "3", "4", "5", "6", "7", "8", "9", "s": + if m.step == stepAgent && len(m.agentPermissions) == 0 && len(m.agentQuestions) > 0 && m.agentRun != nil { + return m.answerAgentQuestion(msg.String()) + } case "left", "h": if m.isFiltering() { break // let the list receive the key as filter input @@ -274,6 +278,8 @@ func (m wizardModel) handleAgentEvent(msg agentEventMsg) (tea.Model, tea.Cmd) { case agent.EventPermission: req := ev.Permission m.agentPermissions = append(m.agentPermissions, req) + case agent.EventQuestion: + m.agentQuestions = append(m.agentQuestions, ev.Question) case agent.EventDone: m.agentDone = true m.closeAgent() @@ -297,6 +303,8 @@ func (m wizardModel) agentFailed(err error) (tea.Model, tea.Cmd) { m.closeAgent() m.agentRun = nil m.agentPermissions = nil + m.agentQuestions = nil + m.agentAnswers = nil if m.agentMode == agentOn || len(m.agentFiles) > 0 { m.err = errors.New("The setup agent failed: " + err.Error()) return m, nil @@ -307,6 +315,32 @@ func (m wizardModel) agentFailed(err error) (tea.Model, tea.Cmd) { return m, m.runInit() } +// answerAgentQuestion records the user's choice for the current question. A +// number picks an option, and s skips the whole request, so the agent goes on +// without answers. When every question of the request has an answer, the +// wizard sends them. +func (m wizardModel) answerAgentQuestion(key string) (tea.Model, tea.Cmd) { + req := m.agentQuestions[0] + if key == "s" { + m.agentQuestions = m.agentQuestions[1:] + m.agentAnswers = nil + return m, answerAgent(m.agentRun, req, nil) + } + question := req.Questions[len(m.agentAnswers)] + choice := int(key[0] - '1') + if choice >= len(question.Options) { + return m, nil + } + m.agentAnswers = append(m.agentAnswers, []string{question.Options[choice].Label}) + if len(m.agentAnswers) < len(req.Questions) { + return m, nil + } + answers := m.agentAnswers + m.agentQuestions = m.agentQuestions[1:] + m.agentAnswers = nil + return m, answerAgent(m.agentRun, req, answers) +} + // closeAgent stops OpenCode if a run is active. func (m wizardModel) closeAgent() { if m.agentRun != nil { diff --git a/cmd/setup/view.go b/cmd/setup/view.go index e78272e3e..fa479afe8 100644 --- a/cmd/setup/view.go +++ b/cmd/setup/view.go @@ -440,6 +440,24 @@ func (m wizardModel) agentView() string { mutedStyle.Render(hint)) return body + question + "\n" } + if len(m.agentQuestions) > 0 { + req := m.agentQuestions[0] + question := req.Questions[len(m.agentAnswers)] + width := m.sdkBoxWidth() - 4 + text := headerStyle.Render(question.Header) + "\n" + wrapText(question.Text, width) + "\n\n" + for i, o := range question.Options { + line := fmt.Sprintf("%d. %s", i+1, o.Label) + if o.Description != "" { + line += " — " + o.Description + } + text += wrapText(line, width) + "\n" + } + hint := fmt.Sprintf("1-%d choose · s skip", len(question.Options)) + if total := len(req.Questions); total > 1 { + hint = fmt.Sprintf("question %d of %d · ", len(m.agentAnswers)+1, total) + hint + } + return body + box(true, m.sdkBoxWidth()).Render(text+"\n"+mutedStyle.Render(hint)) + "\n" + } status := " The agent is working..." if m.agentRun == nil { status = " Starting the agent..." diff --git a/internal/setup/agent/agent.go b/internal/setup/agent/agent.go index 6a94c6a57..7116c0584 100644 --- a/internal/setup/agent/agent.go +++ b/internal/setup/agent/agent.go @@ -61,6 +61,9 @@ const ( EventTool // EventPermission asks the user to allow or reject a tool call. EventPermission + // EventQuestion asks the user to choose an option for each question in + // Question. + EventQuestion // EventFileEdited reports a file that OpenCode changed, in File. EventFileEdited // EventUsage reports the model and the tokens of one finished reply. @@ -78,6 +81,7 @@ type Event struct { Text string Tool ToolCall Permission PermissionRequest + Question QuestionRequest File string Usage Usage } @@ -106,6 +110,30 @@ type PermissionRequest struct { legacy bool } +// QuestionRequest is a set of questions from the agent that waits for the +// user's answers. The agent asks with OpenCode's question tool. +type QuestionRequest struct { + ID string + SessionID string + Questions []Question +} + +// Question is one question with its choices. +type Question struct { + // Header is a short label, at most 30 characters. + Header string + Text string + Options []QuestionOption + // Multiple reports that the user can choose more than one option. + Multiple bool +} + +// QuestionOption is one choice for a question. +type QuestionOption struct { + Label string + Description string +} + // Usage is the model and the token counts of one finished reply. type Usage struct { ProviderID string @@ -131,6 +159,10 @@ type Run interface { Events() <-chan Event // Reply answers a permission request. Reply(ctx context.Context, req PermissionRequest, allow bool) error + // Answer replies to a question request with the chosen option labels, one + // list for each question, in order. A nil answers value dismisses the + // questions, and the agent continues without answers. + Answer(ctx context.Context, req QuestionRequest, answers [][]string) error // Close stops OpenCode. It is safe to call more than once. Close() error } diff --git a/internal/setup/agent/agent_test.go b/internal/setup/agent/agent_test.go index 8af8fe491..100efccd3 100644 --- a/internal/setup/agent/agent_test.go +++ b/internal/setup/agent/agent_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" "net/http/httptest" "strings" @@ -175,6 +176,18 @@ func TestDecoder_Permission(t *testing.T) { assert.Equal(t, PermissionRequest{ID: "per1", SessionID: "ses", Permission: "bash", Patterns: []string{"npm test"}}, out[0].Permission) } +func TestDecoder_Question(t *testing.T) { + d := newDecoder("ses") + + out := decodeAll(d, `{"type":"question.asked","properties":{"id":"que1","sessionID":"ses","questions":[{"question":"Where?","header":"Secret","options":[{"label":".env","description":"gitignored"}]}]}}`) + + require.Len(t, out, 1) + assert.Equal(t, EventQuestion, out[0].Kind) + assert.Equal(t, QuestionRequest{ID: "que1", SessionID: "ses", Questions: []Question{{ + Header: "Secret", Text: "Where?", Options: []QuestionOption{{Label: ".env", Description: "gitignored"}}, + }}}, out[0].Question) +} + func TestDecoder_LegacyPermission(t *testing.T) { d := newDecoder("ses") @@ -253,6 +266,13 @@ func newFakeOpenCode(t *testing.T) *fakeOpenCode { f.mu.Unlock() w.WriteHeader(http.StatusNoContent) }) + mux.HandleFunc("POST /question/{id}/{action}", func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + f.mu.Lock() + f.replies = append(f.replies, r.PathValue("id")+" "+r.PathValue("action")+" "+strings.TrimSpace(string(body))) + f.mu.Unlock() + _, _ = w.Write([]byte("true")) + }) mux.HandleFunc("POST /permission/{id}/reply", func(w http.ResponseWriter, r *http.Request) { var body map[string]string _ = json.NewDecoder(r.Body).Decode(&body) @@ -325,6 +345,21 @@ func TestRun_RepliesToPermission(t *testing.T) { assert.Equal(t, []string{"per1=once", "per1=reject"}, f.replies) } +func TestRun_AnswersQuestion(t *testing.T) { + f := newFakeOpenCode(t) + run, err := startRun(context.Background(), f.client(), Config{}, "task") + require.NoError(t, err) + defer run.Close() + req := QuestionRequest{ID: "que1"} + + require.NoError(t, run.Answer(context.Background(), req, [][]string{{".env"}})) + require.NoError(t, run.Answer(context.Background(), req, nil)) + + f.mu.Lock() + defer f.mu.Unlock() + assert.Equal(t, []string{`que1 reply {"answers":[[".env"]]}`, "que1 reject {}"}, f.replies) +} + func TestRun_StreamEndsEarly_ReportsError(t *testing.T) { f := newFakeOpenCode(t) run, err := startRun(context.Background(), f.client(), Config{}, "task") diff --git a/internal/setup/agent/client.go b/internal/setup/agent/client.go index 42da5619c..82f68aa9e 100644 --- a/internal/setup/agent/client.go +++ b/internal/setup/agent/client.go @@ -116,6 +116,16 @@ func (c *client) replyPermission(ctx context.Context, req PermissionRequest, all return c.do(ctx, http.MethodPost, "/permission/"+url.PathEscape(req.ID)+"/reply", map[string]string{"reply": reply}, nil) } +// answerQuestion replies to a question request, or dismisses it when answers +// is nil. +func (c *client) answerQuestion(ctx context.Context, req QuestionRequest, answers [][]string) error { + path := "/question/" + url.PathEscape(req.ID) + if answers == nil { + return c.do(ctx, http.MethodPost, path+"/reject", map[string]any{}, nil) + } + return c.do(ctx, http.MethodPost, path+"/reply", map[string]any{"answers": answers}, nil) +} + // subscribe opens the event stream. The caller must close the body. func (c *client) subscribe(ctx context.Context) (io.ReadCloser, error) { req, err := c.newRequest(ctx, http.MethodGet, "/event", nil) diff --git a/internal/setup/agent/events.go b/internal/setup/agent/events.go index 8dccd847a..fe3ad486b 100644 --- a/internal/setup/agent/events.go +++ b/internal/setup/agent/events.go @@ -60,6 +60,8 @@ func (d *decoder) decode(data []byte) []Event { out = d.permissionAsked(ev.Properties) case "permission.updated": out = d.legacyPermission(ev.Properties) + case "question.asked": + out = d.questionAsked(ev.Properties) case "file.edited": var p struct { File string `json:"file"` @@ -200,6 +202,34 @@ func (d *decoder) permissionAsked(props json.RawMessage) []Event { }}} } +func (d *decoder) questionAsked(props json.RawMessage) []Event { + var p struct { + ID string `json:"id"` + SessionID string `json:"sessionID"` + Questions []struct { + Question string `json:"question"` + Header string `json:"header"` + Multiple bool `json:"multiple"` + Options []struct { + Label string `json:"label"` + Description string `json:"description"` + } `json:"options"` + } `json:"questions"` + } + if json.Unmarshal(props, &p) != nil || p.SessionID != d.sessionID || len(p.Questions) == 0 { + return nil + } + req := QuestionRequest{ID: p.ID, SessionID: p.SessionID} + for _, q := range p.Questions { + question := Question{Header: q.Header, Text: q.Question, Multiple: q.Multiple} + for _, o := range q.Options { + question.Options = append(question.Options, QuestionOption{Label: o.Label, Description: o.Description}) + } + req.Questions = append(req.Questions, question) + } + return []Event{{Kind: EventQuestion, Question: req}} +} + // legacyPermission reads the permission event of OpenCode versions before // permission.asked. Its pattern is a string or a list of strings. func (d *decoder) legacyPermission(props json.RawMessage) []Event { diff --git a/internal/setup/agent/opencode.go b/internal/setup/agent/opencode.go index ed6616ded..1c89bf29f 100644 --- a/internal/setup/agent/opencode.go +++ b/internal/setup/agent/opencode.go @@ -203,6 +203,10 @@ func (r *openCodeRun) Reply(ctx context.Context, req PermissionRequest, allow bo return r.client.replyPermission(ctx, req, allow) } +func (r *openCodeRun) Answer(ctx context.Context, req QuestionRequest, answers [][]string) error { + return r.client.answerQuestion(ctx, req, answers) +} + func (r *openCodeRun) Close() error { r.closeOnce.Do(func() { close(r.closed) From f3878e078215cf7d24f3d35e54bcb327dda1799c Mon Sep 17 00:00:00 2001 From: Anthony Torns II Date: Mon, 19 Oct 2026 17:45:13 -0700 Subject: [PATCH 5/7] feat(setup): run the agent's commands without approvals The agent now runs shell commands in the project without asking. It asks the user only with the question tool, when it needs information such as where to store a key. Approving every command made a run slow, and most commands only read the project. Some limits stay. Rules deny git commit, git push, git reset, sudo, and rm -rf on absolute paths. Files outside the project, web access, and sub-agents stay denied. Co-Authored-By: Claude Opus 5.5 --- cmd/setup/agent.go | 3 +- cmd/setup/view.go | 4 +- internal/setup/agent/agent_test.go | 18 +++++--- internal/setup/agent/opencode_config.go | 55 ++++++++++++++++++++++--- 4 files changed, 66 insertions(+), 14 deletions(-) diff --git a/cmd/setup/agent.go b/cmd/setup/agent.go index 733253d7a..f37957257 100644 --- a/cmd/setup/agent.go +++ b/cmd/setup/agent.go @@ -109,7 +109,8 @@ func newAgentCmd(svc setup.Service, runner agent.Runner) *cobra.Command { Long: `Runs only the agent step of setup. The setup agent uses OpenCode to edit the project. OpenCode must be installed. -The agent asks before it runs a shell command. It never receives your +The agent runs commands in the project without asking, and it asks you only +when it needs information, such as where to store a key. It never receives your LaunchDarkly credentials: the code that it writes reads them from environment variables.`, RunE: runAgent(svc, runner), diff --git a/cmd/setup/view.go b/cmd/setup/view.go index fa479afe8..691c58910 100644 --- a/cmd/setup/view.go +++ b/cmd/setup/view.go @@ -381,9 +381,9 @@ func (m wizardModel) planView() string { } add(fmt.Sprintf("Create a feature flag in %s / %s", m.selectedProject, m.selectedEnv)) if m.agentMode != agentOff && m.agentRunner != nil { - note := "(asks before it runs a command)" + note := "(asks you only when it needs information)" if m.agentMode == agentAuto { - note = "(asks before it runs a command · uses the template if the agent cannot run)" + note = "(asks you only when it needs information · uses the template if the agent cannot run)" } add("Use the setup agent to add initialization code " + mutedStyle.Render(note)) add("Verify the SDK connects to LaunchDarkly") diff --git a/internal/setup/agent/agent_test.go b/internal/setup/agent/agent_test.go index 100efccd3..ee4234797 100644 --- a/internal/setup/agent/agent_test.go +++ b/internal/setup/agent/agent_test.go @@ -58,18 +58,24 @@ func TestChildEnv_RemovesLDVariablesAndSetsOwn(t *testing.T) { assert.ElementsMatch(t, []string{"PATH=/bin", "HOME=/home/me", "OPENCODE_SERVER_PASSWORD=new"}, env) } -func TestOpenCodeConfig_AsksBeforeShellCommands(t *testing.T) { +func TestOpenCodeConfig_RunsCommandsWithoutApprovals(t *testing.T) { data, err := openCodeConfig() require.NoError(t, err) var cfg struct { - Permission map[string]string `json:"permission"` + Permission map[string]json.RawMessage `json:"permission"` } require.NoError(t, json.Unmarshal(data, &cfg)) - assert.Equal(t, "ask", cfg.Permission["bash"]) - assert.Equal(t, "allow", cfg.Permission["edit"]) - assert.Equal(t, "deny", cfg.Permission["external_directory"]) - assert.Equal(t, "deny", cfg.Permission["webfetch"]) + assert.JSONEq(t, `"allow"`, string(cfg.Permission["edit"])) + assert.JSONEq(t, `"allow"`, string(cfg.Permission["question"]), "the agent can still ask for information") + assert.JSONEq(t, `"deny"`, string(cfg.Permission["external_directory"])) + assert.JSONEq(t, `"deny"`, string(cfg.Permission["webfetch"])) + + bash := string(cfg.Permission["bash"]) + assert.True(t, strings.HasPrefix(bash, `{"*":"allow",`), "the catch-all comes first, because the last matching rule wins: %s", bash) + for _, denied := range []string{"git push*", "git commit*", "git reset*", "sudo *", "rm -rf /*"} { + assert.Contains(t, bash, `"`+denied+`":"deny"`) + } } func TestVersionAtLeast(t *testing.T) { diff --git a/internal/setup/agent/opencode_config.go b/internal/setup/agent/opencode_config.go index c612473ec..8b35363f9 100644 --- a/internal/setup/agent/opencode_config.go +++ b/internal/setup/agent/opencode_config.go @@ -9,18 +9,25 @@ import ( // it in OPENCODE_CONFIG_CONTENT, which overrides the user's own OpenCode // configuration for this process only. // -// The permission rules let OpenCode read and edit files in the project without -// asking. Every shell command needs the user's approval. Rules deny access to -// files outside the project, and they deny web access and sub-agents. +// The agent works without approvals: it reads, edits, and runs shell commands +// in the project without asking. It asks the user only with the question +// tool, when it needs information such as where a key is stored. Rules deny +// files outside the project, web access, sub-agents, and a few commands that +// the agent must never run, such as git push. func openCodeConfig() ([]byte, error) { + bash, err := orderedRules(bashRules) + if err != nil { + return nil, err + } return json.Marshal(map[string]any{ "$schema": "https://opencode.ai/config.json", - "permission": map[string]string{ + "permission": map[string]any{ "read": "allow", "glob": "allow", "grep": "allow", "edit": "allow", - "bash": "ask", + "bash": bash, + "question": "allow", "external_directory": "deny", "webfetch": "deny", "websearch": "deny", @@ -29,6 +36,44 @@ func openCodeConfig() ([]byte, error) { }) } +// bashRules are the shell command rules, in order. OpenCode applies the last +// rule that matches, so the catch-all allow comes first and the denials +// after it. +var bashRules = [][2]string{ + {"*", "allow"}, + {"git commit*", "deny"}, + {"git push*", "deny"}, + {"git reset*", "deny"}, + {"sudo *", "deny"}, + {"rm -rf /*", "deny"}, + {"rm -fr /*", "deny"}, +} + +// orderedRules encodes rules as a JSON object with the keys in the given +// order. A Go map would sort the keys. +func orderedRules(rules [][2]string) (json.RawMessage, error) { + var b strings.Builder + b.WriteByte('{') + for i, r := range rules { + if i > 0 { + b.WriteByte(',') + } + key, err := json.Marshal(r[0]) + if err != nil { + return nil, err + } + value, err := json.Marshal(r[1]) + if err != nil { + return nil, err + } + b.Write(key) + b.WriteByte(':') + b.Write(value) + } + b.WriteByte('}') + return json.RawMessage(b.String()), nil +} + // childEnv returns the environment for the OpenCode process. It removes every // LD_ variable, so OpenCode cannot read the ldcli access token or other ldcli // settings, and it removes the variables that the CLI sets itself. From f3cf06abb28bf0ef640fb321b43ce20ecdf1f12b Mon Sep 17 00:00:00 2001 From: Anthony Torns II Date: Mon, 19 Oct 2026 20:54:05 -0700 Subject: [PATCH 6/7] fix(setup): stop OpenCode when the terminal closes A closed terminal sends SIGHUP, which ended ldcli at once and left the OpenCode process running. The wizard now quits on SIGHUP the same way that Bubble Tea quits on SIGINT and SIGTERM, so it stops OpenCode. `ldcli setup agent` stops OpenCode on SIGINT, SIGTERM, and SIGHUP. Co-Authored-By: Claude Opus 5.5 --- cmd/setup/agent.go | 4 +++- cmd/setup/model.go | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/cmd/setup/agent.go b/cmd/setup/agent.go index f37957257..80b7cee1a 100644 --- a/cmd/setup/agent.go +++ b/cmd/setup/agent.go @@ -11,6 +11,7 @@ import ( "sort" "strconv" "strings" + "syscall" "github.com/spf13/cobra" @@ -151,7 +152,8 @@ func runAgent(svc setup.Service, runner agent.Runner) func(*cobra.Command, []str } task := agentTask(dir, detection, getFlag(cmd, flagKeyFlag), setup.IsInstalled(dir, sdkID)) - ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt) + // Stop OpenCode on Ctrl-C, on kill, and when the terminal closes. + ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM, syscall.SIGHUP) defer stop() if err := runner.Check(ctx); err != nil { diff --git a/cmd/setup/model.go b/cmd/setup/model.go index a22b0c44f..1eeb33206 100644 --- a/cmd/setup/model.go +++ b/cmd/setup/model.go @@ -2,7 +2,10 @@ package setup import ( "io" + "os" + "os/signal" "sync" + "syscall" "github.com/atotto/clipboard" "github.com/charmbracelet/bubbles/list" @@ -267,6 +270,17 @@ func runSetupWizard( } p := tea.NewProgram(m, tea.WithAltScreen()) + // Bubble Tea quits on SIGINT and SIGTERM. A closed terminal sends + // SIGHUP, which would end ldcli at once and leave OpenCode running, so + // it quits the wizard the same way. + hangup := make(chan os.Signal, 1) + signal.Notify(hangup, syscall.SIGHUP) + defer signal.Stop(hangup) + go func() { + if _, ok := <-hangup; ok { + p.Quit() + } + }() _, err = p.Run() // The wizard closes a run when the user quits, but a run can finish // starting after that. OpenCode must not outlive setup. From 4e0ad062e97670094749958e74068f93eca34c31 Mon Sep 17 00:00:00 2001 From: Anthony Torns II Date: Mon, 19 Oct 2026 22:31:28 -0700 Subject: [PATCH 7/7] feat(setup): run the setup agent in a macOS sandbox The agent runs shell commands without approvals, and OpenCode's rules limit only its file tools. In a test run, the agent searched the whole disk with find, read files in another repo, and read the ldcli access token with `ldcli config get`. On macOS, ldcli now starts OpenCode with sandbox-exec and a Seatbelt profile for each run. The sandbox applies to OpenCode and to every command that it runs. It denies the home folder, except the project, OpenCode's own folders, package caches, and toolchains. Other repos, ~/.config/ldcli, and ~/.ssh stay unreadable. System folders and the network stay open. The profile uses resolved paths, because Seatbelt matches the real path behind a symlink. OpenCode's rules also deny `ldcli` and `npx skills` now. The CLI does the LaunchDarkly work, and skills must not be installed into the repo. Linux and Windows run without a sandbox for now. Co-Authored-By: Claude Opus 5.5 --- internal/setup/agent/opencode_config.go | 4 + internal/setup/agent/sandbox.go | 230 ++++++++++++++++++++++++ internal/setup/agent/sandbox_test.go | 108 +++++++++++ internal/setup/agent/server.go | 16 +- 4 files changed, 354 insertions(+), 4 deletions(-) create mode 100644 internal/setup/agent/sandbox.go create mode 100644 internal/setup/agent/sandbox_test.go diff --git a/internal/setup/agent/opencode_config.go b/internal/setup/agent/opencode_config.go index 8b35363f9..70d348855 100644 --- a/internal/setup/agent/opencode_config.go +++ b/internal/setup/agent/opencode_config.go @@ -41,6 +41,10 @@ func openCodeConfig() ([]byte, error) { // after it. var bashRules = [][2]string{ {"*", "allow"}, + // ldcli and the skills installer reach LaunchDarkly or change the repo + // outside the agent's task. The CLI does the LaunchDarkly work itself. + {"ldcli*", "deny"}, + {"npx skills*", "deny"}, {"git commit*", "deny"}, {"git push*", "deny"}, {"git reset*", "deny"}, diff --git a/internal/setup/agent/sandbox.go b/internal/setup/agent/sandbox.go new file mode 100644 index 000000000..58a4af588 --- /dev/null +++ b/internal/setup/agent/sandbox.go @@ -0,0 +1,230 @@ +package agent + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +// The setup agent runs commands without approvals, and OpenCode's rules limit +// only its file tools, not the paths that a shell command touches. So on +// macOS, ldcli starts OpenCode in a Seatbelt sandbox (sandbox-exec), which +// applies to OpenCode and to every command that it runs. The sandbox denies +// the user's home folder, except the project, OpenCode's own folders, package +// caches, and toolchains. Other repos, ~/.config/ldcli, and ~/.ssh stay +// unreadable. System folders and the network stay open, because the model API +// and the package registries need them. + +const sandboxExec = "/usr/bin/sandbox-exec" + +// sandboxPaths are the folders under the home folder that the sandbox opens. +type sandboxPaths struct { + Home string + // ReadWrite are folders that the agent and its commands can change. + ReadWrite []string + // ReadOnly are folders that commands must read but not change. + ReadOnly []string +} + +// readWriteUnderHome are package caches and OpenCode's own folders, relative to +// the home folder. A package install writes to its cache, and OpenCode writes +// its sessions and snapshots. +var readWriteUnderHome = []string{ + ".local/share/opencode", + ".local/state/opencode", + ".cache/opencode", + ".cache/uv", + ".cache/pip", + ".cache/pypoetry", + ".cache/go-build", + ".npm", + ".bun/install/cache", + ".gradle", + ".m2", + ".cargo/registry", + ".cargo/git", + "go/pkg/mod", + "Library/Caches/pip", + "Library/Caches/pypoetry", + "Library/Caches/go-build", + "Library/Caches/Yarn", + "Library/Caches/pnpm", + "Library/pnpm", +} + +// readOnlyUnderHome are toolchains and settings that commands read. +var readOnlyUnderHome = []string{ + ".config/opencode", + ".local/bin", + ".local/share/uv", + ".local/share/mise", + ".pyenv", + ".rbenv", + ".asdf", + ".nvm", + ".volta", + ".bun/bin", + ".deno", + ".rustup", + ".cargo/bin", + "go/bin", + ".sdkman", + ".gitconfig", + ".config/git", +} + +// buildSandboxPaths returns the folders to open for a run in dir. opencodeDir +// is the folder that holds the OpenCode program, so a copy installed under +// the home folder can still start. +func buildSandboxPaths(home, dir, opencodeDir string, env []string) sandboxPaths { + p := sandboxPaths{Home: home, ReadWrite: []string{dir}} + for _, rel := range readWriteUnderHome { + p.ReadWrite = append(p.ReadWrite, filepath.Join(home, rel)) + } + for _, rel := range readOnlyUnderHome { + p.ReadOnly = append(p.ReadOnly, filepath.Join(home, rel)) + } + // OpenCode follows the XDG folder variables. If they point elsewhere, + // open only OpenCode's folder in each. + for name, mode := range map[string]string{"XDG_DATA_HOME": "rw", "XDG_STATE_HOME": "rw", "XDG_CACHE_HOME": "rw", "XDG_CONFIG_HOME": "ro"} { + value := envValue(env, name) + if value == "" { + continue + } + folder := filepath.Join(value, "opencode") + if mode == "rw" { + p.ReadWrite = append(p.ReadWrite, folder) + } else { + p.ReadOnly = append(p.ReadOnly, folder) + } + } + if opencodeDir != "" { + p.ReadOnly = append(p.ReadOnly, opencodeDir) + } + return p +} + +// seatbeltProfile returns the sandbox profile. Later rules take precedence, +// so the allow rules for the open folders come after the deny rule for the +// home folder. +func seatbeltProfile(p sandboxPaths) string { + var b strings.Builder + b.WriteString("(version 1)\n(allow default)\n") + fmt.Fprintf(&b, "(deny file-read* file-write* (subpath %s))\n", sbString(p.Home)) + // Tools read the metadata of the home folder itself, for example to + // resolve a path, without reading what is in it. + fmt.Fprintf(&b, "(allow file-read-metadata (literal %s))\n", sbString(p.Home)) + for _, path := range p.ReadOnly { + fmt.Fprintf(&b, "(allow file-read* (subpath %s))\n", sbString(path)) + } + for _, path := range p.ReadWrite { + fmt.Fprintf(&b, "(allow file-read* file-write* (subpath %s))\n", sbString(path)) + } + return b.String() +} + +// sbString quotes a path for a Seatbelt profile. +func sbString(s string) string { + return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"` +} + +func envValue(env []string, name string) string { + for _, kv := range env { + if k, v, ok := strings.Cut(kv, "="); ok && k == name { + return v + } + } + return "" +} + +// installDir returns the folder to open for the OpenCode program at path. The +// program is often a launcher in the bin folder of its own install, such as +// .../node_modules/opencode-ai/bin, so the folder above bin holds the rest of +// the install. A shared bin folder, such as ~/.local/bin, opens only itself, +// so other programs' data stays closed. +func installDir(home, path string) string { + bin := filepath.Dir(path) + if filepath.Base(bin) != "bin" { + return bin + } + root := filepath.Dir(bin) + shared := map[string]bool{ + home: true, + filepath.Join(home, ".local"): true, + "/usr": true, + "/usr/local": true, + "/opt/homebrew": true, + } + // Never open a folder that holds the home folder, such as / or /Users. + containsHome := root == home || strings.HasPrefix(home, strings.TrimSuffix(root, "/")+"/") + if shared[root] || containsHome { + return bin + } + return root +} + +// realPath resolves the symlinks in path. A path that does not exist yet keeps +// its resolved parent, so a cache folder that a command creates later is +// still covered. +func realPath(path string) string { + if real, err := filepath.EvalSymlinks(path); err == nil { + return real + } + parent := filepath.Dir(path) + if parent == path { + return path + } + return filepath.Join(realPath(parent), filepath.Base(path)) +} + +func mapPaths(paths []string, f func(string) string) []string { + out := make([]string, len(paths)) + for i, p := range paths { + out[i] = f(p) + } + return out +} + +// sandboxCommand returns the program and arguments that run binary with args +// in the sandbox, and a function that removes the profile file. If the +// sandbox is not available, it returns binary and args unchanged. +func sandboxCommand(binary string, args []string, dir string, env []string) (string, []string, func(), error) { + noop := func() {} + if runtime.GOOS != "darwin" { + return binary, args, noop, nil + } + if _, err := os.Stat(sandboxExec); err != nil { + return binary, args, noop, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", nil, noop, fmt.Errorf("could not find the home folder for the agent sandbox: %w", err) + } + opencodeDir := "" + if path, err := exec.LookPath(binary); err == nil { + if real, err := filepath.EvalSymlinks(path); err == nil { + opencodeDir = installDir(home, real) + } + } + // Seatbelt matches the real path, so a folder behind a symlink, such as + // /var/folders on macOS, must be written as the folder it points to. + paths := buildSandboxPaths(realPath(home), realPath(dir), opencodeDir, env) + paths.ReadWrite = mapPaths(paths.ReadWrite, realPath) + paths.ReadOnly = mapPaths(paths.ReadOnly, realPath) + profile := seatbeltProfile(paths) + f, err := os.CreateTemp("", "ldcli-agent-*.sb") + if err != nil { + return "", nil, noop, err + } + if _, err := f.WriteString(profile); err != nil { + f.Close() + os.Remove(f.Name()) + return "", nil, noop, err + } + f.Close() + cleanup := func() { _ = os.Remove(f.Name()) } + return sandboxExec, append([]string{"-f", f.Name(), binary}, args...), cleanup, nil +} diff --git a/internal/setup/agent/sandbox_test.go b/internal/setup/agent/sandbox_test.go new file mode 100644 index 000000000..aa1eb19c8 --- /dev/null +++ b/internal/setup/agent/sandbox_test.go @@ -0,0 +1,108 @@ +package agent + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSeatbeltProfile_DeniesHomeThenOpensFolders(t *testing.T) { + profile := seatbeltProfile(sandboxPaths{ + Home: "/Users/me", + ReadWrite: []string{"/Users/me/app"}, + ReadOnly: []string{`/Users/me/odd "name"`}, + }) + + deny := strings.Index(profile, `(deny file-read* file-write* (subpath "/Users/me"))`) + allow := strings.Index(profile, `(allow file-read* file-write* (subpath "/Users/me/app"))`) + require.NotEqual(t, -1, deny) + require.NotEqual(t, -1, allow) + assert.Less(t, deny, allow, "later rules win, so the allows must follow the deny") + assert.Contains(t, profile, `(allow file-read* (subpath "/Users/me/odd \"name\""))`, "paths are quoted") +} + +func TestBuildSandboxPaths_OpensOnlyOpenCodesXDGFolders(t *testing.T) { + p := buildSandboxPaths("/Users/me", "/Users/me/app", "", []string{"XDG_CONFIG_HOME=/Users/me/.ldcli-local"}) + + assert.Contains(t, p.ReadWrite, "/Users/me/app") + assert.Contains(t, p.ReadWrite, "/Users/me/.local/share/opencode") + assert.Contains(t, p.ReadOnly, "/Users/me/.ldcli-local/opencode") + assert.NotContains(t, p.ReadOnly, "/Users/me/.ldcli-local", "the ldcli configuration next to it stays closed") + for _, path := range append(p.ReadOnly, p.ReadWrite...) { + assert.NotEqual(t, "/Users/me/.config", path) + assert.NotEqual(t, "/Users/me/.config/ldcli", path) + } +} + +func TestInstallDir(t *testing.T) { + assert.Equal(t, "/opt/homebrew/lib/node_modules/opencode-ai", installDir("/Users/me", "/opt/homebrew/lib/node_modules/opencode-ai/bin/opencode.exe")) + assert.Equal(t, "/Users/me/.opencode", installDir("/Users/me", "/Users/me/.opencode/bin/opencode")) + assert.Equal(t, "/Users/me/.local/bin", installDir("/Users/me", "/Users/me/.local/bin/opencode"), "a shared bin folder opens only itself") + assert.Equal(t, "/usr/local/bin", installDir("/Users/me", "/usr/local/bin/opencode")) + assert.Equal(t, "/bin", installDir("/Users/me", "/bin/opencode"), "never open / or another folder that holds the home folder") + assert.Equal(t, "/Users/bin", installDir("/Users/me", "/Users/bin/opencode")) +} + +func TestOpenCodeConfig_DeniesLDCLIAndSkillsInstalls(t *testing.T) { + data, err := openCodeConfig() + require.NoError(t, err) + + assert.Contains(t, string(data), `"ldcli*":"deny"`) + assert.Contains(t, string(data), `"npx skills*":"deny"`) +} + +// TestSandboxCommand_ConfinesReadsToTheProject runs a real sandboxed command on +// macOS, with a fake home folder. +func TestSandboxCommand_ConfinesReadsToTheProject(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("the sandbox is macOS only") + } + if _, err := os.Stat(sandboxExec); err != nil { + t.Skip("sandbox-exec is not available") + } + home := t.TempDir() + t.Setenv("HOME", home) + project := filepath.Join(home, "code", "app") + write := func(path, content string) string { + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + return path + } + inside := write(filepath.Join(project, "main.py"), "inside") + secrets := []string{ + write(filepath.Join(home, ".config", "ldcli", "config.yml"), "access-token: api-secret"), + write(filepath.Join(home, ".ssh", "id_ed25519"), "key"), + write(filepath.Join(home, "code", "other-repo", "main.py"), "other"), + } + + run := func(path string) (string, error) { + name, args, cleanup, err := sandboxCommand("/bin/cat", []string{path}, project, os.Environ()) + require.NoError(t, err) + defer cleanup() + require.Equal(t, sandboxExec, name) + out, err := exec.Command(name, args...).CombinedOutput() + return string(out), err + } + + out, err := run(inside) + require.NoError(t, err, out) + assert.Equal(t, "inside", out) + for _, path := range secrets { + out, err := run(path) + assert.Error(t, err, "read %s", path) + assert.Contains(t, out, "Operation not permitted", path) + } + + name, args, cleanup, err := sandboxCommand("/usr/bin/touch", []string{filepath.Join(project, "new.txt")}, project, os.Environ()) + require.NoError(t, err) + defer cleanup() + out2, err := exec.Command(name, args...).CombinedOutput() + require.NoError(t, err, string(out2)) + assert.FileExists(t, filepath.Join(project, "new.txt"), "the project stays writable") +} diff --git a/internal/setup/agent/server.go b/internal/setup/agent/server.go index 9424720a7..66efcba07 100644 --- a/internal/setup/agent/server.go +++ b/internal/setup/agent/server.go @@ -46,13 +46,19 @@ func startServer(ctx context.Context, binary, dir string, config []byte) (*serve return nil, err } - procCtx, cancel := context.WithCancel(context.Background()) - cmd := exec.CommandContext(procCtx, binary, "serve", "--hostname", "127.0.0.1", "--port", strconv.Itoa(port)) - cmd.Dir = dir - cmd.Env = childEnv(os.Environ(), map[string]string{ + env := childEnv(os.Environ(), map[string]string{ "OPENCODE_SERVER_PASSWORD": password, "OPENCODE_CONFIG_CONTENT": string(config), }) + name, args, cleanup, err := sandboxCommand(binary, []string{"serve", "--hostname", "127.0.0.1", "--port", strconv.Itoa(port)}, dir, env) + if err != nil { + return nil, err + } + + procCtx, cancel := context.WithCancel(context.Background()) + cmd := exec.CommandContext(procCtx, name, args...) + cmd.Dir = dir + cmd.Env = env // Ask OpenCode to stop so it can end its own child processes, and kill it if // it does not stop in time. cmd.Cancel = func() error { @@ -68,6 +74,7 @@ func startServer(ctx context.Context, binary, dir string, config []byte) (*serve if err := cmd.Start(); err != nil { cancel() + cleanup() return nil, fmt.Errorf("could not start OpenCode: %w", err) } s := &server{ @@ -83,6 +90,7 @@ func startServer(ctx context.Context, binary, dir string, config []byte) (*serve } go func() { _ = cmd.Wait() + cleanup() close(s.exited) }()