From 9a2492d2f9fa95ae34ca51d8736f218d3115c78b Mon Sep 17 00:00:00 2001 From: Anthony Torns II Date: Mon, 19 Oct 2026 23:35:54 -0700 Subject: [PATCH 1/3] feat(setup): add an OpenCode adapter for the setup agent The new internal/setup/agent package runs one OpenCode session for a setup task. It starts `opencode serve` in the project folder on the loopback interface, with a random password for each run, and without any LD_ environment variable. It creates the session, sends the task, reads the event stream, and turns the events into text, tool calls, changed files, token usage, permission requests, and questions. It answers permissions and questions, and it stops OpenCode when the run ends. The agent runs shell commands without approvals. OpenCode's rules deny files outside the project, web access, sub-agents, and a few commands such as git push. The agent asks the user only with the question tool. Nothing uses the package yet. The wizard step comes in the next change. Co-Authored-By: Claude Opus 5.5 --- internal/setup/agent/agent.go | 168 ++++++++++ internal/setup/agent/agent_test.go | 388 ++++++++++++++++++++++++ internal/setup/agent/client.go | 147 +++++++++ internal/setup/agent/events.go | 328 ++++++++++++++++++++ internal/setup/agent/opencode.go | 250 +++++++++++++++ internal/setup/agent/opencode_config.go | 96 ++++++ internal/setup/agent/prompt.go | 79 +++++ internal/setup/agent/server.go | 171 +++++++++++ 8 files changed, 1627 insertions(+) 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/internal/setup/agent/agent.go b/internal/setup/agent/agent.go new file mode 100644 index 000000000..7116c0584 --- /dev/null +++ b/internal/setup/agent/agent.go @@ -0,0 +1,168 @@ +// Package agent runs the setup agent: an OpenCode session that adds LaunchDarkly +// SDK initialization code to the user's project. +// +// The CLI owns the OpenCode process. It starts OpenCode in the project +// directory, sends one task, turns the session events into Events, and stops +// OpenCode when the run ends. OpenCode edits the files. The CLI never gives +// OpenCode a LaunchDarkly credential. +package agent + +import "context" + +// Task describes the project and what the agent must do in it. +type Task struct { + // Dir is the project directory that OpenCode edits. + Dir string + SDKID string + Language string + Framework string + EntryPoint string + PackageManager string + // FlagKey is the flag that the new code evaluates. + FlagKey string + // SDKInstalled reports that the install step added the SDK package, so the + // agent does not need to install it. + SDKInstalled bool +} + +// Config is how the agent behaves for one run: the model and the instructions. +type Config struct { + // ProviderID and ModelID select the OpenCode model. If both are empty, + // OpenCode uses its own default model. + ProviderID string + ModelID string + Instructions string +} + +// ConfigSource gives the agent configuration for a task. +// +// The setup-agent AI Config will be the source when the Go AI SDK has an +// OpenCode handler. Until then, DefaultConfigSource supplies built-in +// instructions. +type ConfigSource interface { + Resolve(ctx context.Context, task Task) (Config, error) +} + +// DefaultConfigSource returns the built-in instructions and no model, so +// OpenCode uses the model that the user configured for it. +type DefaultConfigSource struct{} + +func (DefaultConfigSource) Resolve(context.Context, Task) (Config, error) { + return Config{Instructions: DefaultInstructions}, nil +} + +// EventKind identifies what an Event reports. +type EventKind int + +const ( + // EventText carries a part of the assistant's reply in Text. + EventText EventKind = iota + // EventTool reports a tool call that started, finished, or failed. + EventTool + // EventPermission asks the user to allow or reject a tool call. + EventPermission + // EventQuestion asks the user to choose an option for each question in + // Question. + EventQuestion + // EventFileEdited reports a file that OpenCode changed, in File. + EventFileEdited + // EventUsage reports the model and the tokens of one finished reply. + EventUsage + // EventDone reports that the session finished. It is the last event. + EventDone + // EventError reports that the run failed, with the reason in Text. It is + // the last event. + EventError +) + +// Event is one thing that happened in the agent run. +type Event struct { + Kind EventKind + Text string + Tool ToolCall + Permission PermissionRequest + Question QuestionRequest + File string + Usage Usage +} + +// ToolCall is the state of one tool call. +type ToolCall struct { + ID string + // Name is the tool, for example "edit" or "bash". + Name string + // Status is "running", "completed", or "error". + Status string + // Title is OpenCode's short description of the call, if it has one. + Title string +} + +// PermissionRequest is a tool call that waits for the user's answer. +type PermissionRequest struct { + ID string + SessionID string + // Permission is the permission type, for example "bash" or "edit". + Permission string + // Patterns are what the call applies to, for example the command to run. + Patterns []string + // legacy marks a request from an OpenCode version that uses the + // session-scoped reply endpoint. + legacy bool +} + +// QuestionRequest is a set of questions from the agent that waits for the +// user's answers. The agent asks with OpenCode's question tool. +type QuestionRequest struct { + ID string + SessionID string + Questions []Question +} + +// Question is one question with its choices. +type Question struct { + // Header is a short label, at most 30 characters. + Header string + Text string + Options []QuestionOption + // Multiple reports that the user can choose more than one option. + Multiple bool +} + +// QuestionOption is one choice for a question. +type QuestionOption struct { + Label string + Description string +} + +// Usage is the model and the token counts of one finished reply. +type Usage struct { + ProviderID string + ModelID string + InputTokens int + OutputTokens int + Cost float64 +} + +// Runner starts agent runs. +type Runner interface { + // Check returns an error that tells the user why the agent cannot run, or + // nil if it can. + Check(ctx context.Context) error + // Start begins a run for the task. The caller must Close the run. + Start(ctx context.Context, task Task) (Run, error) +} + +// Run is one agent run in progress. +type Run interface { + // Events delivers the run's events. The channel closes after EventDone or + // EventError. + Events() <-chan Event + // Reply answers a permission request. + Reply(ctx context.Context, req PermissionRequest, allow bool) error + // Answer replies to a question request with the chosen option labels, one + // list for each question, in order. A nil answers value dismisses the + // questions, and the agent continues without answers. + Answer(ctx context.Context, req QuestionRequest, answers [][]string) error + // Close stops OpenCode. It is safe to call more than once. + Close() error +} diff --git a/internal/setup/agent/agent_test.go b/internal/setup/agent/agent_test.go new file mode 100644 index 000000000..ee4234797 --- /dev/null +++ b/internal/setup/agent/agent_test.go @@ -0,0 +1,388 @@ +package agent + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildPrompt_NamesTheTaskAndNoCredential(t *testing.T) { + prompt := BuildPrompt(Task{ + SDKID: "node-server", + Language: "JavaScript", + PackageManager: "npm", + EntryPoint: "index.js", + FlagKey: "my-new-flag", + SDKInstalled: true, + }) + + assert.Contains(t, prompt, `"node-server"`) + assert.Contains(t, prompt, `"my-new-flag"`) + assert.Contains(t, prompt, "- Entry point: index.js") + assert.Contains(t, prompt, "- Framework: unknown") + assert.Contains(t, prompt, "already installed") + assert.Contains(t, prompt, "environment variable "+SDKKeyEnvVar) + assert.NotContains(t, prompt, MobileKeyEnvVar, "the prompt names only the SDK's own credential") + assert.NotContains(t, prompt, "did not find an entry point") +} + +func TestCredentialEnvVar(t *testing.T) { + assert.Equal(t, SDKKeyEnvVar, CredentialEnvVar("python-server-sdk")) + assert.Equal(t, ClientSideIDEnvVar, CredentialEnvVar("js-client-sdk")) + assert.Equal(t, MobileKeyEnvVar, CredentialEnvVar("swift-client-sdk")) +} + +func TestBuildPrompt_NoEntryPoint_AsksTheAgentToFindOne(t *testing.T) { + prompt := BuildPrompt(Task{SDKID: "go-server-sdk", FlagKey: "f"}) + + assert.Contains(t, prompt, "did not find an entry point") + assert.Contains(t, prompt, "is not installed") +} + +func TestChildEnv_RemovesLDVariablesAndSetsOwn(t *testing.T) { + env := childEnv( + []string{"PATH=/bin", "LD_ACCESS_TOKEN=api-secret", "ld_base_uri=x", "OPENCODE_SERVER_PASSWORD=old", "HOME=/home/me"}, + map[string]string{"OPENCODE_SERVER_PASSWORD": "new"}, + ) + + assert.ElementsMatch(t, []string{"PATH=/bin", "HOME=/home/me", "OPENCODE_SERVER_PASSWORD=new"}, env) +} + +func TestOpenCodeConfig_RunsCommandsWithoutApprovals(t *testing.T) { + data, err := openCodeConfig() + require.NoError(t, err) + + var cfg struct { + Permission map[string]json.RawMessage `json:"permission"` + } + require.NoError(t, json.Unmarshal(data, &cfg)) + assert.JSONEq(t, `"allow"`, string(cfg.Permission["edit"])) + assert.JSONEq(t, `"allow"`, string(cfg.Permission["question"]), "the agent can still ask for information") + assert.JSONEq(t, `"deny"`, string(cfg.Permission["external_directory"])) + assert.JSONEq(t, `"deny"`, string(cfg.Permission["webfetch"])) + + bash := string(cfg.Permission["bash"]) + assert.True(t, strings.HasPrefix(bash, `{"*":"allow",`), "the catch-all comes first, because the last matching rule wins: %s", bash) + for _, denied := range []string{"git push*", "git commit*", "git reset*", "sudo *", "rm -rf /*"} { + assert.Contains(t, bash, `"`+denied+`":"deny"`) + } +} + +func TestVersionAtLeast(t *testing.T) { + assert.True(t, versionAtLeast("1.19.0", "1.19.0")) + assert.True(t, versionAtLeast("v1.19.3", "1.19.0")) + assert.True(t, versionAtLeast("opencode 2.0.1\n", "1.19.0")) + assert.False(t, versionAtLeast("1.8.9", "1.19.0")) + assert.False(t, versionAtLeast("dev", "1.19.0")) +} + +func TestCheck_NotInstalled(t *testing.T) { + o := &OpenCode{lookPath: func(string) (string, error) { return "", fmt.Errorf("not found") }} + + assert.ErrorIs(t, o.Check(context.Background()), ErrNotInstalled) +} + +func TestCheck_TooOld(t *testing.T) { + o := &OpenCode{ + lookPath: func(string) (string, error) { return "/bin/opencode", nil }, + version: func(context.Context, string) (string, error) { return "1.2.0", nil }, + } + + err := o.Check(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "too old") +} + +func TestCheck_Supported(t *testing.T) { + o := &OpenCode{ + lookPath: func(string) (string, error) { return "/bin/opencode", nil }, + version: func(context.Context, string) (string, error) { return "1.19.3", nil }, + } + + assert.NoError(t, o.Check(context.Background())) +} + +func TestReadSSE_JoinsDataLinesAndSkipsOtherFields(t *testing.T) { + stream := "event: message\nid: 1\ndata: {\"a\":\ndata: 1}\n\n: comment\ndata: {\"b\":2}\n\n" + var got []string + + err := readSSE(strings.NewReader(stream), func(data []byte) bool { + got = append(got, string(data)) + return true + }) + + require.NoError(t, err) + assert.Equal(t, []string{"{\"a\":\n1}", "{\"b\":2}"}, got) +} + +func decodeAll(d *decoder, events ...string) []Event { + var out []Event + for _, e := range events { + out = append(out, d.decode([]byte(e))...) + } + return out +} + +func TestDecoder_TextDeltasOnlyForTextParts(t *testing.T) { + d := newDecoder("ses") + + out := decodeAll(d, + `{"type":"message.part.updated","properties":{"part":{"id":"p1","sessionID":"ses","type":"reasoning"}}}`, + `{"type":"message.part.delta","properties":{"sessionID":"ses","partID":"p1","field":"text","delta":"thinking"}}`, + `{"type":"message.part.updated","properties":{"part":{"id":"p2","sessionID":"ses","type":"text"}}}`, + `{"type":"message.part.delta","properties":{"sessionID":"ses","partID":"p2","field":"text","delta":"Hello"}}`, + ) + + require.Len(t, out, 1) + assert.Equal(t, Event{Kind: EventText, Text: "Hello"}, out[0]) +} + +func TestDecoder_IgnoresOtherSessions(t *testing.T) { + d := newDecoder("ses") + + out := decodeAll(d, + `{"type":"message.part.updated","properties":{"part":{"id":"p1","sessionID":"other","type":"tool","callID":"c1","tool":"bash","state":{"status":"running"}}}}`, + `{"type":"permission.asked","properties":{"id":"per1","sessionID":"other","permission":"bash","patterns":["rm -rf /"]}}`, + `{"type":"session.idle","properties":{"sessionID":"other"}}`, + ) + + assert.Empty(t, out) +} + +func TestDecoder_ToolStatusChangesOnce(t *testing.T) { + d := newDecoder("ses") + tool := func(status string) string { + return fmt.Sprintf(`{"type":"message.part.updated","properties":{"part":{"id":"p1","sessionID":"ses","type":"tool","callID":"c1","tool":"edit","state":{"status":%q,"title":"index.js"}}}}`, status) + } + + out := decodeAll(d, tool("pending"), tool("running"), tool("running"), tool("completed")) + + require.Len(t, out, 2) + assert.Equal(t, ToolCall{ID: "c1", Name: "edit", Status: "running", Title: "index.js"}, out[0].Tool) + assert.Equal(t, "completed", out[1].Tool.Status) +} + +func TestDecoder_Permission(t *testing.T) { + d := newDecoder("ses") + + out := decodeAll(d, `{"type":"permission.asked","properties":{"id":"per1","sessionID":"ses","permission":"bash","patterns":["npm test"]}}`) + + require.Len(t, out, 1) + assert.Equal(t, EventPermission, out[0].Kind) + assert.Equal(t, PermissionRequest{ID: "per1", SessionID: "ses", Permission: "bash", Patterns: []string{"npm test"}}, out[0].Permission) +} + +func TestDecoder_Question(t *testing.T) { + d := newDecoder("ses") + + out := decodeAll(d, `{"type":"question.asked","properties":{"id":"que1","sessionID":"ses","questions":[{"question":"Where?","header":"Secret","options":[{"label":".env","description":"gitignored"}]}]}}`) + + require.Len(t, out, 1) + assert.Equal(t, EventQuestion, out[0].Kind) + assert.Equal(t, QuestionRequest{ID: "que1", SessionID: "ses", Questions: []Question{{ + Header: "Secret", Text: "Where?", Options: []QuestionOption{{Label: ".env", Description: "gitignored"}}, + }}}, out[0].Question) +} + +func TestDecoder_LegacyPermission(t *testing.T) { + d := newDecoder("ses") + + out := decodeAll(d, `{"type":"permission.updated","properties":{"id":"per1","sessionID":"ses","type":"bash","pattern":"npm test","title":"Run npm test"}}`) + + require.Len(t, out, 1) + assert.True(t, out[0].Permission.legacy) + assert.Equal(t, []string{"npm test"}, out[0].Permission.Patterns) +} + +func TestDecoder_UsageOncePerFinishedReply(t *testing.T) { + d := newDecoder("ses") + msg := func(completed int) string { + return fmt.Sprintf(`{"type":"message.updated","properties":{"info":{"id":"m1","sessionID":"ses","role":"assistant","providerID":"anthropic","modelID":"claude","cost":0.5,"time":{"created":1,"completed":%d},"tokens":{"input":10,"output":20}}}}`, completed) + } + + out := decodeAll(d, msg(0), msg(2), msg(2)) + + require.Len(t, out, 1) + assert.Equal(t, Usage{ProviderID: "anthropic", ModelID: "claude", InputTokens: 10, OutputTokens: 20, Cost: 0.5}, out[0].Usage) +} + +func TestDecoder_StopsAfterDoneOrError(t *testing.T) { + d := newDecoder("ses") + + out := decodeAll(d, + `{"type":"session.error","properties":{"sessionID":"ses","error":{"name":"ProviderAuthError","data":{"message":"no API key"}}}}`, + `{"type":"session.idle","properties":{"sessionID":"ses"}}`, + ) + + require.Len(t, out, 1) + assert.Equal(t, Event{Kind: EventError, Text: "no API key"}, out[0]) +} + +// fakeOpenCode is an HTTP server with the parts of the OpenCode API that a +// run uses. The test writes events to the stream with emit. +type fakeOpenCode struct { + t *testing.T + srv *httptest.Server + stream chan string + + mu sync.Mutex + prompt map[string]any + replies []string +} + +func newFakeOpenCode(t *testing.T) *fakeOpenCode { + f := &fakeOpenCode{t: t, stream: make(chan string, 16)} + mux := http.NewServeMux() + mux.HandleFunc("POST /session", func(w http.ResponseWriter, r *http.Request) { + if _, pass, ok := r.BasicAuth(); !ok || pass != "secret" { + w.WriteHeader(http.StatusUnauthorized) + return + } + _, _ = w.Write([]byte(`{"id":"ses"}`)) + }) + mux.HandleFunc("GET /event", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + w.(http.Flusher).Flush() + for { + select { + case <-r.Context().Done(): + return + case e := <-f.stream: + fmt.Fprintf(w, "data: %s\n\n", e) + w.(http.Flusher).Flush() + } + } + }) + mux.HandleFunc("POST /session/ses/prompt_async", func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + f.mu.Lock() + f.prompt = body + f.mu.Unlock() + w.WriteHeader(http.StatusNoContent) + }) + mux.HandleFunc("POST /question/{id}/{action}", func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + f.mu.Lock() + f.replies = append(f.replies, r.PathValue("id")+" "+r.PathValue("action")+" "+strings.TrimSpace(string(body))) + f.mu.Unlock() + _, _ = w.Write([]byte("true")) + }) + mux.HandleFunc("POST /permission/{id}/reply", func(w http.ResponseWriter, r *http.Request) { + var body map[string]string + _ = json.NewDecoder(r.Body).Decode(&body) + f.mu.Lock() + f.replies = append(f.replies, r.PathValue("id")+"="+body["reply"]) + f.mu.Unlock() + _, _ = w.Write([]byte("true")) + }) + f.srv = httptest.NewServer(mux) + t.Cleanup(f.srv.Close) + return f +} + +func (f *fakeOpenCode) client() *client { + return &client{baseURL: f.srv.URL, password: "secret", http: &http.Client{Timeout: 5 * time.Second}} +} + +func (f *fakeOpenCode) emit(e string) { f.stream <- e } + +func nextEvent(t *testing.T, run Run) (Event, bool) { + t.Helper() + select { + case ev, ok := <-run.Events(): + return ev, ok + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for an event") + return Event{}, false + } +} + +func TestRun_SendsTaskAndStreamsEventsUntilIdle(t *testing.T) { + f := newFakeOpenCode(t) + cfg := Config{ProviderID: "anthropic", ModelID: "claude", Instructions: "be brief"} + + run, err := startRun(context.Background(), f.client(), cfg, "set up the SDK") + require.NoError(t, err) + defer run.Close() + + f.mu.Lock() + assert.Equal(t, "be brief", f.prompt["system"]) + assert.Equal(t, map[string]any{"providerID": "anthropic", "modelID": "claude"}, f.prompt["model"]) + f.mu.Unlock() + + f.emit(`{"type":"file.edited","properties":{"file":"index.js"}}`) + f.emit(`{"type":"session.idle","properties":{"sessionID":"ses"}}`) + + ev, _ := nextEvent(t, run) + assert.Equal(t, Event{Kind: EventFileEdited, File: "index.js"}, ev) + ev, _ = nextEvent(t, run) + assert.Equal(t, EventDone, ev.Kind) + _, open := nextEvent(t, run) + assert.False(t, open, "the channel closes after the last event") +} + +func TestRun_RepliesToPermission(t *testing.T) { + f := newFakeOpenCode(t) + run, err := startRun(context.Background(), f.client(), Config{}, "task") + require.NoError(t, err) + defer run.Close() + + f.emit(`{"type":"permission.asked","properties":{"id":"per1","sessionID":"ses","permission":"bash","patterns":["npm test"]}}`) + ev, _ := nextEvent(t, run) + require.Equal(t, EventPermission, ev.Kind) + + require.NoError(t, run.Reply(context.Background(), ev.Permission, true)) + require.NoError(t, run.Reply(context.Background(), ev.Permission, false)) + + f.mu.Lock() + defer f.mu.Unlock() + assert.Equal(t, []string{"per1=once", "per1=reject"}, f.replies) +} + +func TestRun_AnswersQuestion(t *testing.T) { + f := newFakeOpenCode(t) + run, err := startRun(context.Background(), f.client(), Config{}, "task") + require.NoError(t, err) + defer run.Close() + req := QuestionRequest{ID: "que1"} + + require.NoError(t, run.Answer(context.Background(), req, [][]string{{".env"}})) + require.NoError(t, run.Answer(context.Background(), req, nil)) + + f.mu.Lock() + defer f.mu.Unlock() + assert.Equal(t, []string{`que1 reply {"answers":[[".env"]]}`, "que1 reject {}"}, f.replies) +} + +func TestRun_StreamEndsEarly_ReportsError(t *testing.T) { + f := newFakeOpenCode(t) + run, err := startRun(context.Background(), f.client(), Config{}, "task") + require.NoError(t, err) + defer run.Close() + + f.srv.CloseClientConnections() + + ev, _ := nextEvent(t, run) + assert.Equal(t, EventError, ev.Kind) +} + +func TestRun_CloseIsSafeTwice(t *testing.T) { + f := newFakeOpenCode(t) + run, err := startRun(context.Background(), f.client(), Config{}, "task") + require.NoError(t, err) + + assert.NoError(t, run.Close()) + assert.NoError(t, run.Close()) +} diff --git a/internal/setup/agent/client.go b/internal/setup/agent/client.go new file mode 100644 index 000000000..82f68aa9e --- /dev/null +++ b/internal/setup/agent/client.go @@ -0,0 +1,147 @@ +package agent + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +// serverUsername is the basic auth user name that OpenCode expects when +// OPENCODE_SERVER_PASSWORD is set and OPENCODE_SERVER_USERNAME is not. +const serverUsername = "opencode" + +// client calls the HTTP API of one OpenCode server. +type client struct { + baseURL string + password string + http *http.Client +} + +func (c *client) newRequest(ctx context.Context, method, path string, body any) (*http.Request, error) { + var r io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + r = bytes.NewReader(data) + } + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, r) + if err != nil { + return nil, err + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + req.SetBasicAuth(serverUsername, c.password) + return req, nil +} + +// do sends a request and decodes a JSON response into out, if out is not nil. +func (c *client) do(ctx context.Context, method, path string, body, out any) error { + req, err := c.newRequest(ctx, method, path, body) + if err != nil { + return err + } + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + msg, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return &statusError{method: method, path: path, code: resp.StatusCode, body: strings.TrimSpace(string(msg))} + } + if out == nil { + return nil + } + return json.NewDecoder(resp.Body).Decode(out) +} + +type statusError struct { + method, path string + code int + body string +} + +func (e *statusError) Error() string { + if e.body == "" { + return fmt.Sprintf("OpenCode returned HTTP %d for %s %s", e.code, e.method, e.path) + } + return fmt.Sprintf("OpenCode returned HTTP %d for %s %s: %s", e.code, e.method, e.path, e.body) +} + +func (c *client) createSession(ctx context.Context) (string, error) { + var s struct { + ID string `json:"id"` + } + if err := c.do(ctx, http.MethodPost, "/session", map[string]string{"title": "ldcli setup"}, &s); err != nil { + return "", err + } + if s.ID == "" { + return "", fmt.Errorf("OpenCode created a session without an ID") + } + return s.ID, nil +} + +// promptAsync sends the task and returns when OpenCode accepts it. The reply +// arrives on the event stream. +func (c *client) promptAsync(ctx context.Context, sessionID string, cfg Config, text string) error { + body := map[string]any{ + "parts": []map[string]string{{"type": "text", "text": text}}, + } + if cfg.Instructions != "" { + body["system"] = cfg.Instructions + } + if cfg.ProviderID != "" && cfg.ModelID != "" { + body["model"] = map[string]string{"providerID": cfg.ProviderID, "modelID": cfg.ModelID} + } + return c.do(ctx, http.MethodPost, "/session/"+url.PathEscape(sessionID)+"/prompt_async", body, nil) +} + +func (c *client) replyPermission(ctx context.Context, req PermissionRequest, allow bool) error { + reply := "reject" + if allow { + reply = "once" + } + if req.legacy { + path := "/session/" + url.PathEscape(req.SessionID) + "/permissions/" + url.PathEscape(req.ID) + return c.do(ctx, http.MethodPost, path, map[string]string{"response": reply}, nil) + } + return c.do(ctx, http.MethodPost, "/permission/"+url.PathEscape(req.ID)+"/reply", map[string]string{"reply": reply}, nil) +} + +// answerQuestion replies to a question request, or dismisses it when answers +// is nil. +func (c *client) answerQuestion(ctx context.Context, req QuestionRequest, answers [][]string) error { + path := "/question/" + url.PathEscape(req.ID) + if answers == nil { + return c.do(ctx, http.MethodPost, path+"/reject", map[string]any{}, nil) + } + return c.do(ctx, http.MethodPost, path+"/reply", map[string]any{"answers": answers}, nil) +} + +// subscribe opens the event stream. The caller must close the body. +func (c *client) subscribe(ctx context.Context) (io.ReadCloser, error) { + req, err := c.newRequest(ctx, http.MethodGet, "/event", nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "text/event-stream") + // The stream stays open for the whole run, so it must not use the client + // timeout of the other calls. + resp, err := (&http.Client{Transport: c.http.Transport}).Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + return nil, &statusError{method: http.MethodGet, path: "/event", code: resp.StatusCode} + } + return resp.Body, nil +} diff --git a/internal/setup/agent/events.go b/internal/setup/agent/events.go new file mode 100644 index 000000000..fe3ad486b --- /dev/null +++ b/internal/setup/agent/events.go @@ -0,0 +1,328 @@ +package agent + +import ( + "bufio" + "bytes" + "encoding/json" + "io" + "strings" +) + +// rawEvent is one OpenCode bus event as the /event stream sends it. +type rawEvent struct { + Type string `json:"type"` + Properties json.RawMessage `json:"properties"` +} + +// decoder turns OpenCode bus events into Events for one session. OpenCode +// sends events for every session on the server, so the decoder drops the +// events of other sessions. It also drops repeats: OpenCode sends a part again +// each time that the part changes. +type decoder struct { + sessionID string + // partTypes records the type of each part, because a text delta does not + // say whether it belongs to a text part or a reasoning part. + partTypes map[string]string + // toolStatus records the last status sent for each tool call. + toolStatus map[string]string + usageSent map[string]bool + finished bool +} + +func newDecoder(sessionID string) *decoder { + return &decoder{ + sessionID: sessionID, + partTypes: map[string]string{}, + toolStatus: map[string]string{}, + usageSent: map[string]bool{}, + } +} + +// decode returns the Events for one raw event. After the decoder returns +// EventDone or EventError, it returns nothing. +func (d *decoder) decode(data []byte) []Event { + if d.finished { + return nil + } + var ev rawEvent + if err := json.Unmarshal(data, &ev); err != nil { + return nil + } + var out []Event + switch ev.Type { + case "message.part.updated": + out = d.partUpdated(ev.Properties) + case "message.part.delta": + out = d.partDelta(ev.Properties) + case "message.updated": + out = d.messageUpdated(ev.Properties) + case "permission.asked": + out = d.permissionAsked(ev.Properties) + case "permission.updated": + out = d.legacyPermission(ev.Properties) + case "question.asked": + out = d.questionAsked(ev.Properties) + case "file.edited": + var p struct { + File string `json:"file"` + } + if json.Unmarshal(ev.Properties, &p) == nil && p.File != "" { + out = []Event{{Kind: EventFileEdited, File: p.File}} + } + case "session.idle": + var p struct { + SessionID string `json:"sessionID"` + } + if json.Unmarshal(ev.Properties, &p) == nil && p.SessionID == d.sessionID { + out = []Event{{Kind: EventDone}} + } + case "session.error": + out = d.sessionError(ev.Properties) + } + for _, e := range out { + if e.Kind == EventDone || e.Kind == EventError { + d.finished = true + } + } + return out +} + +type part struct { + ID string `json:"id"` + SessionID string `json:"sessionID"` + Type string `json:"type"` + CallID string `json:"callID"` + Tool string `json:"tool"` + State struct { + Status string `json:"status"` + Title string `json:"title"` + } `json:"state"` +} + +func (d *decoder) partUpdated(props json.RawMessage) []Event { + var p struct { + Part part `json:"part"` + // Delta is set by OpenCode versions that send text changes on the part + // update and not as a separate delta event. + Delta string `json:"delta"` + } + if json.Unmarshal(props, &p) != nil || p.Part.SessionID != d.sessionID { + return nil + } + d.partTypes[p.Part.ID] = p.Part.Type + switch p.Part.Type { + case "text": + if p.Delta != "" { + return []Event{{Kind: EventText, Text: p.Delta}} + } + case "tool": + status := p.Part.State.Status + // A pending call has no input yet, so there is nothing to show. + if status == "pending" || status == "" || d.toolStatus[p.Part.CallID] == status { + return nil + } + d.toolStatus[p.Part.CallID] = status + return []Event{{Kind: EventTool, Tool: ToolCall{ + ID: p.Part.CallID, + Name: p.Part.Tool, + Status: status, + Title: p.Part.State.Title, + }}} + } + return nil +} + +func (d *decoder) partDelta(props json.RawMessage) []Event { + var p struct { + SessionID string `json:"sessionID"` + PartID string `json:"partID"` + Field string `json:"field"` + Delta string `json:"delta"` + } + if json.Unmarshal(props, &p) != nil || p.SessionID != d.sessionID { + return nil + } + // Show only the reply text, not the model's reasoning. + if p.Field != "text" || d.partTypes[p.PartID] != "text" || p.Delta == "" { + return nil + } + return []Event{{Kind: EventText, Text: p.Delta}} +} + +func (d *decoder) messageUpdated(props json.RawMessage) []Event { + var p struct { + Info struct { + ID string `json:"id"` + SessionID string `json:"sessionID"` + Role string `json:"role"` + ProviderID string `json:"providerID"` + ModelID string `json:"modelID"` + Cost float64 `json:"cost"` + Time struct { + Completed float64 `json:"completed"` + } `json:"time"` + Tokens struct { + Input int `json:"input"` + Output int `json:"output"` + } `json:"tokens"` + } `json:"info"` + } + if json.Unmarshal(props, &p) != nil { + return nil + } + info := p.Info + if info.SessionID != d.sessionID || info.Role != "assistant" || info.Time.Completed == 0 || d.usageSent[info.ID] { + return nil + } + d.usageSent[info.ID] = true + return []Event{{Kind: EventUsage, Usage: Usage{ + ProviderID: info.ProviderID, + ModelID: info.ModelID, + InputTokens: info.Tokens.Input, + OutputTokens: info.Tokens.Output, + Cost: info.Cost, + }}} +} + +func (d *decoder) permissionAsked(props json.RawMessage) []Event { + var p struct { + ID string `json:"id"` + SessionID string `json:"sessionID"` + Permission string `json:"permission"` + Patterns []string `json:"patterns"` + } + if json.Unmarshal(props, &p) != nil || p.SessionID != d.sessionID { + return nil + } + return []Event{{Kind: EventPermission, Permission: PermissionRequest{ + ID: p.ID, + SessionID: p.SessionID, + Permission: p.Permission, + Patterns: p.Patterns, + }}} +} + +func (d *decoder) questionAsked(props json.RawMessage) []Event { + var p struct { + ID string `json:"id"` + SessionID string `json:"sessionID"` + Questions []struct { + Question string `json:"question"` + Header string `json:"header"` + Multiple bool `json:"multiple"` + Options []struct { + Label string `json:"label"` + Description string `json:"description"` + } `json:"options"` + } `json:"questions"` + } + if json.Unmarshal(props, &p) != nil || p.SessionID != d.sessionID || len(p.Questions) == 0 { + return nil + } + req := QuestionRequest{ID: p.ID, SessionID: p.SessionID} + for _, q := range p.Questions { + question := Question{Header: q.Header, Text: q.Question, Multiple: q.Multiple} + for _, o := range q.Options { + question.Options = append(question.Options, QuestionOption{Label: o.Label, Description: o.Description}) + } + req.Questions = append(req.Questions, question) + } + return []Event{{Kind: EventQuestion, Question: req}} +} + +// legacyPermission reads the permission event of OpenCode versions before +// permission.asked. Its pattern is a string or a list of strings. +func (d *decoder) legacyPermission(props json.RawMessage) []Event { + var p struct { + ID string `json:"id"` + SessionID string `json:"sessionID"` + Type string `json:"type"` + Pattern json.RawMessage `json:"pattern"` + Title string `json:"title"` + } + if json.Unmarshal(props, &p) != nil || p.SessionID != d.sessionID { + return nil + } + var patterns []string + var one string + if json.Unmarshal(p.Pattern, &one) == nil && one != "" { + patterns = []string{one} + } else { + _ = json.Unmarshal(p.Pattern, &patterns) + } + if len(patterns) == 0 && p.Title != "" { + patterns = []string{p.Title} + } + return []Event{{Kind: EventPermission, Permission: PermissionRequest{ + ID: p.ID, + SessionID: p.SessionID, + Permission: p.Type, + Patterns: patterns, + legacy: true, + }}} +} + +func (d *decoder) sessionError(props json.RawMessage) []Event { + var p struct { + SessionID string `json:"sessionID"` + Error struct { + Name string `json:"name"` + Data struct { + Message string `json:"message"` + } `json:"data"` + } `json:"error"` + } + if json.Unmarshal(props, &p) != nil { + return nil + } + // An error without a session ID can come from any session, but on a server + // that ldcli started for one run, it belongs to this run. + if p.SessionID != "" && p.SessionID != d.sessionID { + return nil + } + msg := p.Error.Data.Message + if msg == "" { + msg = p.Error.Name + } + if msg == "" { + msg = "OpenCode reported an error." + } + return []Event{{Kind: EventError, Text: msg}} +} + +// readSSE reads a server-sent event stream and calls fn with the data of each +// event. It stops when fn returns false or the stream ends. +func readSSE(r io.Reader, fn func(data []byte) bool) error { + scanner := bufio.NewScanner(r) + // A part update can carry a whole file diff, which is longer than the + // scanner's default line limit. + scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) + var data bytes.Buffer + for scanner.Scan() { + line := scanner.Text() + if line == "" { + if data.Len() > 0 { + if !fn(data.Bytes()) { + return nil + } + data.Reset() + } + continue + } + value, ok := strings.CutPrefix(line, "data:") + if !ok { + continue // event names, IDs, and comments carry nothing that we use + } + if data.Len() > 0 { + data.WriteByte('\n') + } + data.WriteString(strings.TrimPrefix(value, " ")) + } + if err := scanner.Err(); err != nil { + return err + } + if data.Len() > 0 { + fn(data.Bytes()) + } + return nil +} diff --git a/internal/setup/agent/opencode.go b/internal/setup/agent/opencode.go new file mode 100644 index 000000000..1c89bf29f --- /dev/null +++ b/internal/setup/agent/opencode.go @@ -0,0 +1,250 @@ +package agent + +import ( + "context" + "errors" + "fmt" + "io" + "os/exec" + "regexp" + "strconv" + "strings" + "sync" + "time" +) + +// MinOpenCodeVersion is the oldest OpenCode version that the adapter was +// tested with. +const MinOpenCodeVersion = "1.19.0" + +// OpenCode is the Runner that uses the OpenCode CLI. +type OpenCode struct { + // Binary is the OpenCode executable. The default is "opencode" on the PATH. + Binary string + // Config gives the model and the instructions for each run. + Config ConfigSource + + // lookPath and version are fields so that tests can replace them. + lookPath func(string) (string, error) + version func(ctx context.Context, binary string) (string, error) +} + +// NewOpenCode returns a Runner that uses OpenCode from the PATH with the +// built-in configuration. +func NewOpenCode() *OpenCode { + return &OpenCode{Binary: "opencode", Config: DefaultConfigSource{}} +} + +// ErrNotInstalled reports that OpenCode is not on the PATH. +var ErrNotInstalled = errors.New("OpenCode is not installed. Install it from https://opencode.ai, then run setup again") + +func (o *OpenCode) binary() string { + if o.Binary == "" { + return "opencode" + } + return o.Binary +} + +func (o *OpenCode) Check(ctx context.Context) error { + lookPath := o.lookPath + if lookPath == nil { + lookPath = exec.LookPath + } + path, err := lookPath(o.binary()) + if err != nil { + return ErrNotInstalled + } + version := o.version + if version == nil { + version = binaryVersion + } + v, err := version(ctx, path) + if err != nil { + return fmt.Errorf("could not read the OpenCode version: %w", err) + } + if !versionAtLeast(v, MinOpenCodeVersion) { + return fmt.Errorf("OpenCode %s is too old. Setup needs version %s or later. Run `opencode upgrade`, then run setup again", v, MinOpenCodeVersion) + } + return nil +} + +func (o *OpenCode) Start(ctx context.Context, task Task) (Run, error) { + source := o.Config + if source == nil { + source = DefaultConfigSource{} + } + cfg, err := source.Resolve(ctx, task) + if err != nil { + return nil, fmt.Errorf("could not get the setup agent configuration: %w", err) + } + config, err := openCodeConfig() + if err != nil { + return nil, err + } + srv, err := startServer(ctx, o.binary(), task.Dir, config) + if err != nil { + return nil, err + } + + run, err := startRun(ctx, srv.client, cfg, BuildPrompt(task)) + if err != nil { + srv.stop() + return nil, err + } + run.stopServer = srv.stop + go func() { + // If OpenCode exits during the run, end the event stream so the caller + // gets an error and does not wait forever. + select { + case <-srv.exited: + run.fail(fmt.Sprintf("OpenCode stopped during the run: %s", srv.output.String())) + case <-run.closed: + } + }() + return run, nil +} + +// openCodeRun is a Run on an OpenCode server. +type openCodeRun struct { + client *client + sessionID string + events chan Event + cancel context.CancelFunc + closed chan struct{} + stopServer func() + + mu sync.Mutex + finished bool + closeOnce sync.Once +} + +// startRun creates a session, subscribes to its events, and sends the task. +func startRun(ctx context.Context, c *client, cfg Config, prompt string) (*openCodeRun, error) { + sessionID, err := c.createSession(ctx) + if err != nil { + return nil, fmt.Errorf("could not create an OpenCode session: %w", err) + } + streamCtx, cancel := context.WithCancel(context.Background()) + // Subscribe before the prompt goes out, so no event of the run is missed. + stream, err := c.subscribe(streamCtx) + if err != nil { + cancel() + return nil, fmt.Errorf("could not read OpenCode events: %w", err) + } + r := &openCodeRun{ + client: c, + sessionID: sessionID, + events: make(chan Event, 64), + cancel: cancel, + closed: make(chan struct{}), + } + go r.pump(stream) + + if err := c.promptAsync(ctx, sessionID, cfg, prompt); err != nil { + r.Close() + return nil, fmt.Errorf("could not send the task to OpenCode: %w", err) + } + return r, nil +} + +func (r *openCodeRun) pump(stream io.ReadCloser) { + defer stream.Close() + dec := newDecoder(r.sessionID) + err := readSSE(stream, func(data []byte) bool { + for _, ev := range dec.decode(data) { + if !r.send(ev) { + return false + } + } + return !dec.finished + }) + if dec.finished { + return + } + select { + case <-r.closed: + return + default: + } + msg := "The OpenCode event stream ended before the run finished." + if err != nil { + msg = fmt.Sprintf("Could not read OpenCode events: %v", err) + } + r.fail(msg) +} + +// send delivers an event, and closes the channel after the last event. It +// returns false if the run is already finished or closed. +func (r *openCodeRun) send(ev Event) bool { + r.mu.Lock() + defer r.mu.Unlock() + if r.finished { + return false + } + select { + case r.events <- ev: + case <-r.closed: + return false + } + if ev.Kind == EventDone || ev.Kind == EventError { + r.finished = true + close(r.events) + } + return true +} + +func (r *openCodeRun) fail(msg string) { + r.send(Event{Kind: EventError, Text: msg}) +} + +func (r *openCodeRun) Events() <-chan Event { return r.events } + +func (r *openCodeRun) Reply(ctx context.Context, req PermissionRequest, allow bool) error { + return r.client.replyPermission(ctx, req, allow) +} + +func (r *openCodeRun) Answer(ctx context.Context, req QuestionRequest, answers [][]string) error { + return r.client.answerQuestion(ctx, req, answers) +} + +func (r *openCodeRun) Close() error { + r.closeOnce.Do(func() { + close(r.closed) + r.cancel() + if r.stopServer != nil { + r.stopServer() + } + }) + return nil +} + +// binaryVersion runs `opencode --version`. +func binaryVersion(ctx context.Context, binary string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, binary, "--version").Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +var versionPattern = regexp.MustCompile(`(\d+)\.(\d+)\.(\d+)`) + +// versionAtLeast reports whether the first version in v is the same as or +// newer than min. It returns false if v has no version number. +func versionAtLeast(v, min string) bool { + got := versionPattern.FindStringSubmatch(v) + want := versionPattern.FindStringSubmatch(min) + if got == nil || want == nil { + return false + } + for i := 1; i <= 3; i++ { + g, _ := strconv.Atoi(got[i]) + w, _ := strconv.Atoi(want[i]) + if g != w { + return g > w + } + } + return true +} diff --git a/internal/setup/agent/opencode_config.go b/internal/setup/agent/opencode_config.go new file mode 100644 index 000000000..8b35363f9 --- /dev/null +++ b/internal/setup/agent/opencode_config.go @@ -0,0 +1,96 @@ +package agent + +import ( + "encoding/json" + "strings" +) + +// openCodeConfig returns the OpenCode configuration for a run. The CLI passes +// it in OPENCODE_CONFIG_CONTENT, which overrides the user's own OpenCode +// configuration for this process only. +// +// The agent works without approvals: it reads, edits, and runs shell commands +// in the project without asking. It asks the user only with the question +// tool, when it needs information such as where a key is stored. Rules deny +// files outside the project, web access, sub-agents, and a few commands that +// the agent must never run, such as git push. +func openCodeConfig() ([]byte, error) { + bash, err := orderedRules(bashRules) + if err != nil { + return nil, err + } + return json.Marshal(map[string]any{ + "$schema": "https://opencode.ai/config.json", + "permission": map[string]any{ + "read": "allow", + "glob": "allow", + "grep": "allow", + "edit": "allow", + "bash": bash, + "question": "allow", + "external_directory": "deny", + "webfetch": "deny", + "websearch": "deny", + "task": "deny", + }, + }) +} + +// bashRules are the shell command rules, in order. OpenCode applies the last +// rule that matches, so the catch-all allow comes first and the denials +// after it. +var bashRules = [][2]string{ + {"*", "allow"}, + {"git commit*", "deny"}, + {"git push*", "deny"}, + {"git reset*", "deny"}, + {"sudo *", "deny"}, + {"rm -rf /*", "deny"}, + {"rm -fr /*", "deny"}, +} + +// orderedRules encodes rules as a JSON object with the keys in the given +// order. A Go map would sort the keys. +func orderedRules(rules [][2]string) (json.RawMessage, error) { + var b strings.Builder + b.WriteByte('{') + for i, r := range rules { + if i > 0 { + b.WriteByte(',') + } + key, err := json.Marshal(r[0]) + if err != nil { + return nil, err + } + value, err := json.Marshal(r[1]) + if err != nil { + return nil, err + } + b.Write(key) + b.WriteByte(':') + b.Write(value) + } + b.WriteByte('}') + return json.RawMessage(b.String()), nil +} + +// childEnv returns the environment for the OpenCode process. It removes every +// LD_ variable, so OpenCode cannot read the ldcli access token or other ldcli +// settings, and it removes the variables that the CLI sets itself. +func childEnv(parent []string, set map[string]string) []string { + env := make([]string, 0, len(parent)+len(set)) + for _, kv := range parent { + name, _, _ := strings.Cut(kv, "=") + if strings.HasPrefix(strings.ToUpper(name), "LD_") { + continue + } + if _, ok := set[name]; ok { + continue + } + env = append(env, kv) + } + for name, value := range set { + env = append(env, name+"="+value) + } + return env +} diff --git a/internal/setup/agent/prompt.go b/internal/setup/agent/prompt.go new file mode 100644 index 000000000..6dab20696 --- /dev/null +++ b/internal/setup/agent/prompt.go @@ -0,0 +1,79 @@ +package agent + +import ( + "fmt" + "strings" +) + +// Credential environment variables. The agent writes code that reads the +// credential from one of these, so the credential never goes into a prompt or +// into the user's source files. +const ( + SDKKeyEnvVar = "LAUNCHDARKLY_SDK_KEY" + ClientSideIDEnvVar = "LAUNCHDARKLY_CLIENT_SIDE_ID" + MobileKeyEnvVar = "LAUNCHDARKLY_MOBILE_KEY" +) + +// clientSideSDKs read the client-side ID, and mobileSDKs read the mobile key. +// Every other SDK reads the server-side SDK key. +var ( + clientSideSDKs = map[string]bool{"react-client-sdk": true, "js-client-sdk": true} + mobileSDKs = map[string]bool{"react-native": true, "android": true, "swift-client-sdk": true} +) + +// CredentialEnvVar returns the environment variable that the agent's code +// must read for the SDK. +func CredentialEnvVar(sdkID string) string { + switch { + case clientSideSDKs[sdkID]: + return ClientSideIDEnvVar + case mobileSDKs[sdkID]: + return MobileKeyEnvVar + } + return SDKKeyEnvVar +} + +// DefaultInstructions is the system prompt that the built-in configuration +// uses. +const DefaultInstructions = `You are the LaunchDarkly setup agent. You add LaunchDarkly SDK initialization code to the project in the current directory. + +Follow these rules: +- Change only files inside the project directory. +- Make the smallest change that initializes the SDK where the application starts, and evaluates the given flag once. +- Read the SDK credential from an environment variable. Never write a key, an ID, or a token into a file. +- Do not install packages unless the task says that the SDK is not installed. Do not add other dependencies. +- Do not commit, push, or change git configuration. +- Follow the code style of the project. +- When you finish, list the files that you changed and say in one sentence how the user can see the flag value.` + +// BuildPrompt returns the task message for one run. +func BuildPrompt(t Task) string { + var b strings.Builder + fmt.Fprintf(&b, "Set up the LaunchDarkly SDK %q in this project.\n\n", t.SDKID) + + b.WriteString("Facts that ldcli detected:\n") + writeFact(&b, "Language", t.Language) + writeFact(&b, "Framework", t.Framework) + writeFact(&b, "Package manager", t.PackageManager) + writeFact(&b, "Entry point", t.EntryPoint) + b.WriteString("\n") + + if t.SDKInstalled { + b.WriteString("The SDK package is already installed. Do not install it again.\n") + } else { + b.WriteString("The SDK package is not installed. Install it with the project's package manager.\n") + } + fmt.Fprintf(&b, "Evaluate the boolean flag %q once after the SDK initializes, and print or log its value.\n", t.FlagKey) + fmt.Fprintf(&b, "Read the credential from the environment variable %s.\n", CredentialEnvVar(t.SDKID)) + if t.EntryPoint == "" { + b.WriteString("ldcli did not find an entry point. Find where the application starts before you edit.\n") + } + return b.String() +} + +func writeFact(b *strings.Builder, name, value string) { + if value == "" { + value = "unknown" + } + fmt.Fprintf(b, "- %s: %s\n", name, value) +} diff --git a/internal/setup/agent/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 60c08f09c9892af70a45f737d5c85c0ea863185e Mon Sep 17 00:00:00 2001 From: Anthony Torns II Date: Tue, 20 Oct 2026 11:22:11 -0700 Subject: [PATCH 2/3] fix(setup): end the events of a closed run, set the OpenCode user, and map every client SDK Close now sends a final error event and closes Events, if the run did not finish. A reader of Events no longer waits forever. The child environment now sets OPENCODE_SERVER_USERNAME, so a value in the user's shell does not break the basic auth. CredentialEnvVar now covers every client-side and mobile SDK ID and alias that the installer and the templates accept. Co-Authored-By: Claude Opus 5.5 --- internal/setup/agent/agent_test.go | 36 ++++++++++++++++++++++++++++++ internal/setup/agent/opencode.go | 19 ++++++++++++++++ internal/setup/agent/prompt.go | 26 ++++++++++++++++++--- internal/setup/agent/server.go | 3 +++ 4 files changed, 81 insertions(+), 3 deletions(-) diff --git a/internal/setup/agent/agent_test.go b/internal/setup/agent/agent_test.go index ee4234797..17e799af9 100644 --- a/internal/setup/agent/agent_test.go +++ b/internal/setup/agent/agent_test.go @@ -378,6 +378,42 @@ func TestRun_StreamEndsEarly_ReportsError(t *testing.T) { assert.Equal(t, EventError, ev.Kind) } +func TestRun_CloseEndsTheEventsOfAnUnfinishedRun(t *testing.T) { + f := newFakeOpenCode(t) + run, err := startRun(context.Background(), f.client(), Config{}, "task") + require.NoError(t, err) + + done := make(chan []Event) + go func() { + var got []Event + for ev := range run.Events() { + got = append(got, ev) + } + done <- got + }() + require.NoError(t, run.Close()) + + select { + case got := <-done: + require.NotEmpty(t, got) + assert.Equal(t, EventError, got[len(got)-1].Kind, "the last event says that the run did not finish") + case <-time.After(2 * time.Second): + t.Fatal("Events stayed open after Close") + } +} + +func TestCredentialEnvVar_CoversEveryAlias(t *testing.T) { + for _, id := range []string{"js-client-sdk", "launchdarkly-react-client-sdk", "vue-client-sdk", "electron-client-sdk"} { + assert.Equal(t, ClientSideIDEnvVar, CredentialEnvVar(id), id) + } + for _, id := range []string{"android", "android-client-sdk", "ios-client-sdk", "swift-client-sdk", "react-native", "react-native-client-sdk", "flutter-client-sdk"} { + assert.Equal(t, MobileKeyEnvVar, CredentialEnvVar(id), id) + } + for _, id := range []string{"node-server-sdk", "python-server-sdk", "go-server-sdk"} { + assert.Equal(t, SDKKeyEnvVar, CredentialEnvVar(id), id) + } +} + func TestRun_CloseIsSafeTwice(t *testing.T) { f := newFakeOpenCode(t) run, err := startRun(context.Background(), f.client(), Config{}, "task") diff --git a/internal/setup/agent/opencode.go b/internal/setup/agent/opencode.go index 1c89bf29f..eb4ccf2e8 100644 --- a/internal/setup/agent/opencode.go +++ b/internal/setup/agent/opencode.go @@ -211,6 +211,7 @@ func (r *openCodeRun) Close() error { r.closeOnce.Do(func() { close(r.closed) r.cancel() + r.end() if r.stopServer != nil { r.stopServer() } @@ -218,6 +219,24 @@ func (r *openCodeRun) Close() error { return nil } +// end gives a run that Close stopped before its last event a final error +// event, and closes the channel, so a reader of Events does not wait forever. +// A send that waits on a full channel returns when r.closed closes, so end +// does not block. +func (r *openCodeRun) end() { + r.mu.Lock() + defer r.mu.Unlock() + if r.finished { + return + } + r.finished = true + select { + case r.events <- Event{Kind: EventError, Text: "The run was closed before it finished."}: + default: + } + close(r.events) +} + // binaryVersion runs `opencode --version`. func binaryVersion(ctx context.Context, binary string) (string, error) { ctx, cancel := context.WithTimeout(ctx, 10*time.Second) diff --git a/internal/setup/agent/prompt.go b/internal/setup/agent/prompt.go index 6dab20696..16fb5cf84 100644 --- a/internal/setup/agent/prompt.go +++ b/internal/setup/agent/prompt.go @@ -15,10 +15,30 @@ const ( ) // clientSideSDKs read the client-side ID, and mobileSDKs read the mobile key. -// Every other SDK reads the server-side SDK key. +// Every other SDK reads the server-side SDK key. The lists include each ID +// and alias that the installer and the templates accept. 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} + clientSideSDKs = map[string]bool{ + "js-client-sdk": true, + "launchdarkly-js-client-sdk": true, + "react-client-sdk": true, + "launchdarkly-react-client-sdk": true, + "vue-client-sdk": true, + "node-client-sdk": true, + "electron-client-sdk": true, + } + mobileSDKs = map[string]bool{ + "android": true, + "android-client-sdk": true, + "ios-client-sdk": true, + "swift-client-sdk": true, + "react-native": true, + "react-native-client-sdk": true, + "flutter-client-sdk": true, + "dotnet-client-sdk": true, + "cpp-client-sdk": true, + "roku-client-sdk": true, + } ) // CredentialEnvVar returns the environment variable that the agent's code diff --git a/internal/setup/agent/server.go b/internal/setup/agent/server.go index 9424720a7..c7849193a 100644 --- a/internal/setup/agent/server.go +++ b/internal/setup/agent/server.go @@ -51,6 +51,9 @@ func startServer(ctx context.Context, binary, dir string, config []byte) (*serve cmd.Dir = dir cmd.Env = childEnv(os.Environ(), map[string]string{ "OPENCODE_SERVER_PASSWORD": password, + // Set the user name too, so a value in the user's shell does not + // make OpenCode refuse the client's basic auth. + "OPENCODE_SERVER_USERNAME": serverUsername, "OPENCODE_CONFIG_CONTENT": string(config), }) // Ask OpenCode to stop so it can end its own child processes, and kill it if From a6fafd5fe7397f7d080e94f0c3c359ffe76ca96e Mon Sep 17 00:00:00 2001 From: Anthony Torns II Date: Tue, 20 Oct 2026 11:28:20 -0700 Subject: [PATCH 3/3] fix(setup): name the Flutter web credential and fix comment wording The prompt for flutter-client-sdk now also names LAUNCHDARKLY_CLIENT_SIDE_ID for an app that runs on the web. The error of a stopped run now uses active voice, and two comments now follow the sentence limits. Co-Authored-By: Claude Opus 5.5 --- internal/setup/agent/agent_test.go | 7 +++++++ internal/setup/agent/opencode.go | 9 ++++----- internal/setup/agent/prompt.go | 7 +++++++ internal/setup/agent/server.go | 4 ++-- 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/internal/setup/agent/agent_test.go b/internal/setup/agent/agent_test.go index 17e799af9..b1a407f3a 100644 --- a/internal/setup/agent/agent_test.go +++ b/internal/setup/agent/agent_test.go @@ -414,6 +414,13 @@ func TestCredentialEnvVar_CoversEveryAlias(t *testing.T) { } } +func TestBuildPrompt_FlutterNamesTheWebCredential(t *testing.T) { + p := BuildPrompt(Task{SDKID: "flutter-client-sdk", FlagKey: "f"}) + assert.Contains(t, p, MobileKeyEnvVar) + assert.Contains(t, p, "If the app runs on the web, read the client-side ID from "+ClientSideIDEnvVar) + assert.NotContains(t, BuildPrompt(Task{SDKID: "android", FlagKey: "f"}), ClientSideIDEnvVar) +} + func TestRun_CloseIsSafeTwice(t *testing.T) { f := newFakeOpenCode(t) run, err := startRun(context.Background(), f.client(), Config{}, "task") diff --git a/internal/setup/agent/opencode.go b/internal/setup/agent/opencode.go index eb4ccf2e8..824e76ca2 100644 --- a/internal/setup/agent/opencode.go +++ b/internal/setup/agent/opencode.go @@ -219,10 +219,9 @@ func (r *openCodeRun) Close() error { return nil } -// end gives a run that Close stopped before its last event a final error -// event, and closes the channel, so a reader of Events does not wait forever. -// A send that waits on a full channel returns when r.closed closes, so end -// does not block. +// end sends a final error event when Close stops an unfinished run. +// It closes the event channel, so readers do not wait forever. A send that +// waits on a full channel returns when r.closed closes, so end does not block. func (r *openCodeRun) end() { r.mu.Lock() defer r.mu.Unlock() @@ -231,7 +230,7 @@ func (r *openCodeRun) end() { } r.finished = true select { - case r.events <- Event{Kind: EventError, Text: "The run was closed before it finished."}: + case r.events <- Event{Kind: EventError, Text: "The run stopped before it finished."}: default: } close(r.events) diff --git a/internal/setup/agent/prompt.go b/internal/setup/agent/prompt.go index 16fb5cf84..d50968a1a 100644 --- a/internal/setup/agent/prompt.go +++ b/internal/setup/agent/prompt.go @@ -41,6 +41,10 @@ var ( } ) +// flutterSDK reads the mobile key on mobile platforms and the client-side ID on +// the web. The SDK ID does not give the platform, so the prompt names both. +const flutterSDK = "flutter-client-sdk" + // CredentialEnvVar returns the environment variable that the agent's code // must read for the SDK. func CredentialEnvVar(sdkID string) string { @@ -85,6 +89,9 @@ func BuildPrompt(t Task) string { } fmt.Fprintf(&b, "Evaluate the boolean flag %q once after the SDK initializes, and print or log its value.\n", t.FlagKey) fmt.Fprintf(&b, "Read the credential from the environment variable %s.\n", CredentialEnvVar(t.SDKID)) + if t.SDKID == flutterSDK { + fmt.Fprintf(&b, "If the app runs on the web, read the client-side ID from %s instead.\n", ClientSideIDEnvVar) + } if t.EntryPoint == "" { b.WriteString("ldcli did not find an entry point. Find where the application starts before you edit.\n") } diff --git a/internal/setup/agent/server.go b/internal/setup/agent/server.go index c7849193a..fcb760ad5 100644 --- a/internal/setup/agent/server.go +++ b/internal/setup/agent/server.go @@ -51,8 +51,8 @@ func startServer(ctx context.Context, binary, dir string, config []byte) (*serve cmd.Dir = dir cmd.Env = childEnv(os.Environ(), map[string]string{ "OPENCODE_SERVER_PASSWORD": password, - // Set the user name too, so a value in the user's shell does not - // make OpenCode refuse the client's basic auth. + // Set the user name too. A shell value then cannot make OpenCode + // refuse the client's basic auth. "OPENCODE_SERVER_USERNAME": serverUsername, "OPENCODE_CONFIG_CONTENT": string(config), })