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..b1a407f3a --- /dev/null +++ b/internal/setup/agent/agent_test.go @@ -0,0 +1,431 @@ +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_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 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") + 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..824e76ca2 --- /dev/null +++ b/internal/setup/agent/opencode.go @@ -0,0 +1,268 @@ +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() + r.end() + if r.stopServer != nil { + r.stopServer() + } + }) + return nil +} + +// 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() + if r.finished { + return + } + r.finished = true + select { + case r.events <- Event{Kind: EventError, Text: "The run stopped 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) + 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..d50968a1a --- /dev/null +++ b/internal/setup/agent/prompt.go @@ -0,0 +1,106 @@ +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. The lists include each ID +// and alias that the installer and the templates accept. +var ( + 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, + } +) + +// 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 { + 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.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") + } + 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..fcb760ad5 --- /dev/null +++ b/internal/setup/agent/server.go @@ -0,0 +1,174 @@ +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, + // 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), + }) + // 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)) +}