Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
5b8bd44
fix(setup): make the agent sandbox work with uv and git
atornsii Oct 20, 2026
9378e4a
feat(setup): give the agent the project, environment, and provider keys
atornsii Oct 20, 2026
0abfee0
Merge remote-tracking branch 'origin/atorns/agent-5-opencode-handler'…
atornsii Oct 20, 2026
8a56521
Merge remote-tracking branch 'origin/atorns/agent-5-opencode-handler'…
atornsii Oct 20, 2026
78ed988
fix(setup): give the fallback prompt the new facts and skip blank dot…
atornsii Oct 20, 2026
1ac85d2
Merge remote-tracking branch 'origin/atorns/agent-5-opencode-handler'…
atornsii Oct 20, 2026
d1cf8e9
fix(setup): always give OpenCode a TMPDIR
atornsii Oct 20, 2026
80bf2bd
Merge remote-tracking branch 'origin/atorns/agent-5-opencode-handler'…
atornsii Oct 20, 2026
9030cfe
Merge remote-tracking branch 'origin/atorns/agent-5-opencode-handler'…
atornsii Oct 20, 2026
0b01d03
Merge remote-tracking branch 'origin/atorns/agent-5-opencode-handler'…
atornsii Oct 20, 2026
3ef4e62
fix(setup): allow metadata reads on parents under the closed temporar…
atornsii Oct 20, 2026
e68aa99
Merge remote-tracking branch 'origin/atorns/agent-5-opencode-handler'…
atornsii Oct 20, 2026
3283712
fix(setup): close the project's .env files to the agent
atornsii Oct 21, 2026
42f2421
fix(setup): escape the project path in the sandbox's .env rule
atornsii Oct 21, 2026
69f081c
Merge branch 'atorns/agent-5-opencode-handler' into atorns/agent-6-ru…
atornsii Oct 21, 2026
5392d02
Merge branch 'atorns/agent-5-opencode-handler' into atorns/agent-6-ru…
atornsii Oct 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions cmd/setup/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,22 @@ import (

"github.com/spf13/cobra"

"github.com/launchdarkly/ldcli/cmd/cliflags"
"github.com/launchdarkly/ldcli/internal/errors"
"github.com/launchdarkly/ldcli/internal/setup"
"github.com/launchdarkly/ldcli/internal/setup/agent"
)

// agentTask builds the agent task from the wizard's detection result.
func agentTask(dir string, detection *setup.DetectResult, flagKey string, sdkInstalled bool) agent.Task {
t := agent.Task{Dir: dir, FlagKey: flagKey, SDKInstalled: sdkInstalled}
func agentTask(dir string, detection *setup.DetectResult, flagKey string, sdkInstalled bool, projectKey, envKey string) agent.Task {
t := agent.Task{
Dir: dir,
FlagKey: flagKey,
SDKInstalled: sdkInstalled,
ProjectKey: projectKey,
EnvironmentKey: envKey,
ProviderKeys: agent.ProviderKeys(dir, os.Environ()),
}
if detection != nil {
t.SDKID = detection.SDKID
t.Language = detection.Language
Expand Down Expand Up @@ -95,6 +103,8 @@ variables.`,
_ = cmd.MarkFlagRequired(sdkIDFlag)
cmd.Flags().String(flagKeyFlag, "", "Feature flag key that the new code evaluates")
_ = cmd.MarkFlagRequired(flagKeyFlag)
cmd.Flags().String(cliflags.ProjectFlag, "", "LaunchDarkly project key, given to the agent as a fact")
cmd.Flags().String(cliflags.EnvironmentFlag, "", "LaunchDarkly environment key, given to the agent as a fact")
cmd.Flags().String(sdkKeyFlag, "", "Server-side SDK key, shown in the closing instructions only")
cmd.Flags().String(clientIDFlag, "", "Client-side environment ID, shown in the closing instructions only")
cmd.Flags().String(mobileFlag, "", "Mobile SDK key, shown in the closing instructions only")
Expand Down Expand Up @@ -123,7 +133,8 @@ func runAgent(svc setup.Service, runner agent.Runner) func(*cobra.Command, []str
detection.EntryPoint, detection.EntryPointExists = setup.EntryPointFor(dir, sdkID)
detection.PackageManager = setup.PackageManagerFor(dir, sdkID)
}
task := agentTask(dir, detection, getFlag(cmd, flagKeyFlag), setup.IsInstalled(dir, sdkID))
task := agentTask(dir, detection, getFlag(cmd, flagKeyFlag), setup.IsInstalled(dir, sdkID),
getFlag(cmd, cliflags.ProjectFlag), getFlag(cmd, cliflags.EnvironmentFlag))

// Stop OpenCode on Ctrl-C, on kill, and when the terminal closes.
ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM, syscall.SIGHUP)
Expand Down
6 changes: 5 additions & 1 deletion cmd/setup/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,9 @@ func startedAgent(t *testing.T, run *fakeRun) wizardModel {

func TestWizard_StartsAgentWithTheTask(t *testing.T) {
runner := &fakeRunner{run: newFakeRun()}
m, cmd := update(t, agentWizard(runner), flagCreatedMsg{key: "my-new-flag"})
w := agentWizard(runner)
w.selectedProject, w.selectedEnv = "proj", "prod"
m, cmd := update(t, w, flagCreatedMsg{key: "my-new-flag"})
require.Equal(t, stepAgent, m.step)

msg := cmd()
Expand All @@ -123,6 +125,8 @@ func TestWizard_StartsAgentWithTheTask(t *testing.T) {
assert.Equal(t, "node-server", runner.tasks[0].SDKID)
assert.Equal(t, "index.js", runner.tasks[0].EntryPoint)
assert.Equal(t, "my-new-flag", runner.tasks[0].FlagKey)
assert.Equal(t, "proj", runner.tasks[0].ProjectKey)
assert.Equal(t, "prod", runner.tasks[0].EnvironmentKey)
assert.NotEmpty(t, runner.tasks[0].Dir)
assert.Contains(t, m.View(), "Starting the agent")
}
Expand Down
4 changes: 2 additions & 2 deletions cmd/setup/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,13 +190,13 @@ func isRemoteSession() bool {
func (m wizardModel) startAgent() tea.Cmd {
runner, tracker := m.agentRunner, m.agentRuns
sdkInstalled := m.installResult != nil && !m.installResult.Failed
task := agentTask("", m.detectResult, m.flagKey, sdkInstalled)
detection, flagKey, project, env := m.detectResult, m.flagKey, m.selectedProject, m.selectedEnv
return func() tea.Msg {
dir, err := os.Getwd()
if err != nil {
return agentFailedMsg{err: err}
}
task.Dir = dir
task := agentTask(dir, detection, flagKey, sdkInstalled, project, env)
ctx := context.Background()
if err := runner.Check(ctx); err != nil {
return agentFailedMsg{err: err}
Expand Down
7 changes: 7 additions & 0 deletions internal/setup/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ type Task struct {
// SDKInstalled reports that the install step added the SDK package, so the
// agent does not need to install it.
SDKInstalled bool
// ProjectKey and EnvironmentKey are where setup created the flag. They
// are empty when they are not known.
ProjectKey string
EnvironmentKey string
// ProviderKeys are the names of the model provider keys that are set. The
// values never reach the agent.
ProviderKeys []string
}

// Config is how the agent behaves for one run: the model and the instructions.
Expand Down
29 changes: 28 additions & 1 deletion internal/setup/agent/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ func TestOpenCodeConfig_RunsCommandsWithoutApprovals(t *testing.T) {
Permission map[string]json.RawMessage `json:"permission"`
}
require.NoError(t, json.Unmarshal(data, &cfg))
assert.JSONEq(t, `"allow"`, string(cfg.Permission["edit"]))
assert.True(t, strings.HasPrefix(string(cfg.Permission["edit"]), `{"*":"allow",`), "edits run without approval, except on .env files")
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"]))
Expand Down Expand Up @@ -440,3 +440,30 @@ func TestRun_CloseIsSafeTwice(t *testing.T) {
assert.NoError(t, run.Close())
assert.NoError(t, run.Close())
}

func TestBuildPrompt_HasTheSameFactsAsBuildFacts(t *testing.T) {
task := Task{SDKID: "python-server-sdk", FlagKey: "f", ProjectKey: "proj", EnvironmentKey: "prod", ProviderKeys: []string{"ANTHROPIC_API_KEY"}}
for _, p := range []string{BuildPrompt(task), BuildFacts(task)} {
assert.Contains(t, p, "LaunchDarkly project: proj")
assert.Contains(t, p, "LaunchDarkly environment: prod")
assert.Contains(t, p, "ANTHROPIC_API_KEY (names only)")
assert.Contains(t, p, "$TMPDIR")
}
}

func TestOpenCodeConfig_ReadAndEditRefuseEnvFiles(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))

for _, tool := range []string{"read", "edit"} {
raw := string(cfg.Permission[tool])
assert.Contains(t, raw, `"*.env":"deny"`, tool)
assert.Contains(t, raw, `"*.env.*":"deny"`, tool)
assert.Contains(t, raw, `"*.env.example":"allow"`, tool)
assert.Less(t, strings.Index(raw, `"*.env.*":"deny"`), strings.Index(raw, `"*.env.example":"allow"`), "the last match wins, so the example rule comes after the deny")
}
}
21 changes: 19 additions & 2 deletions internal/setup/agent/opencode_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,19 @@ func openCodeConfig() ([]byte, error) {
if err != nil {
return nil, err
}
// The read and edit tools refuse .env files, which hold the user's keys.
// The sandbox closes them for every other command too.
envFiles, err := orderedRules(envFileRules)
if err != nil {
return nil, err
}
return json.Marshal(map[string]any{
"$schema": "https://opencode.ai/config.json",
"permission": map[string]any{
"read": "allow",
"read": envFiles,
"glob": "allow",
"grep": "allow",
"edit": "allow",
"edit": envFiles,
"bash": bash,
"question": "allow",
"external_directory": "deny",
Expand Down Expand Up @@ -55,6 +61,17 @@ var bashRules = [][2]string{

// orderedRules encodes rules as a JSON object with the keys in the given
// order. A Go map would sort the keys.
// envFileRules allow every file except .env files. The last matching rule
// wins, so the example files stay open.
var envFileRules = [][2]string{
{"*", "allow"},
{"*.env", "deny"},
{"*.env.*", "deny"},
{"*.env.example", "allow"},
{"*.env.sample", "allow"},
{"*.env.template", "allow"},
}
Comment thread
atornsii marked this conversation as resolved.

func orderedRules(rules [][2]string) (json.RawMessage, error) {
var b strings.Builder
b.WriteByte('{')
Expand Down
22 changes: 22 additions & 0 deletions internal/setup/agent/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,29 @@ func BuildFacts(t Task) string {
installed = "yes"
}
writeFact(&b, "SDK package installed", installed)
writeFact(&b, "LaunchDarkly project", t.ProjectKey)
writeFact(&b, "LaunchDarkly environment", t.EnvironmentKey)
writeFact(&b, "Flag created by setup", t.FlagKey)
writeFact(&b, "Environment variable for the SDK credential", CredentialEnvVar(t.SDKID))
writeFact(&b, "Model provider keys that are set", providerKeysFact(t.ProviderKeys))
if t.SDKID == flutterSDK {
writeFact(&b, "Environment variable for a Flutter web app", ClientSideIDEnvVar)
}
b.WriteString("\n" + setupRules)
return b.String()
}

// setupRules ends both prompts, so the agent gets the same rules with or
// without the AI Config.
const setupRules = "ldcli gives the user the SDK credential. Do not look for keys, print the environment, or ask the user for a key. The project's .env files are closed to you, so do not read them or run the app with them. Write variable names in .env.example. Use your temporary folder in $TMPDIR, not /tmp.\n"

func providerKeysFact(names []string) string {
if len(names) == 0 {
return "none"
}
return strings.Join(names, ", ") + " (names only)"
}

// BuildPrompt returns the task message for one run.
func BuildPrompt(t Task) string {
var b strings.Builder
Expand All @@ -103,6 +121,9 @@ func BuildPrompt(t Task) string {
writeFact(&b, "Framework", t.Framework)
writeFact(&b, "Package manager", t.PackageManager)
writeFact(&b, "Entry point", t.EntryPoint)
writeFact(&b, "LaunchDarkly project", t.ProjectKey)
writeFact(&b, "LaunchDarkly environment", t.EnvironmentKey)
writeFact(&b, "Model provider keys that are set", providerKeysFact(t.ProviderKeys))
b.WriteString("\n")

if t.SDKInstalled {
Expand All @@ -118,6 +139,7 @@ func BuildPrompt(t Task) string {
if t.EntryPoint == "" {
b.WriteString("ldcli did not find an entry point. Find where the application starts before you edit.\n")
}
b.WriteString(setupRules)
Comment thread
atornsii marked this conversation as resolved.
return b.String()
}

Expand Down
70 changes: 70 additions & 0 deletions internal/setup/agent/provider_keys.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package agent

import (
"bufio"
"os"
"path/filepath"
"strings"
)

// providerKeyNames are the environment variables that hold the API keys of
// the model providers that LaunchDarkly AI SDKs support.
var providerKeyNames = []string{
"ANTHROPIC_API_KEY",
"OPENAI_API_KEY",
"AZURE_OPENAI_API_KEY",
"GEMINI_API_KEY",
"GOOGLE_API_KEY",
"MISTRAL_API_KEY",
"COHERE_API_KEY",
"GROQ_API_KEY",
"AWS_ACCESS_KEY_ID",
"AWS_PROFILE",
}

// ProviderKeys returns the names of the provider keys that are set, in the
// environment or in the project's .env file. It never returns a value. The
// agent uses the answer to decide whether it can run the app, so it never
// has to ask for a key or print the environment.
func ProviderKeys(dir string, environ []string) []string {
set := map[string]bool{}
for _, kv := range environ {
if name, value, ok := strings.Cut(kv, "="); ok && strings.TrimSpace(value) != "" {
set[name] = true
}
}
for name := range dotEnvNames(filepath.Join(dir, ".env")) {
set[name] = true
}
var found []string
for _, name := range providerKeyNames {
if set[name] {
found = append(found, name)
}
}
return found
}

// dotEnvNames returns the names in a .env file that have a value.
func dotEnvNames(path string) map[string]bool {
names := map[string]bool{}
f, err := os.Open(path)
if err != nil {
return names
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
line = strings.TrimPrefix(line, "export ")
if line == "" || strings.HasPrefix(line, "#") {
continue
}
name, value, ok := strings.Cut(line, "=")
value = strings.TrimSpace(strings.Trim(strings.TrimSpace(value), `"'`))
if ok && value != "" {
names[strings.TrimSpace(name)] = true
}
}
return names
}
39 changes: 39 additions & 0 deletions internal/setup/agent/provider_keys_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package agent

import (
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestProviderKeys_NamesFromTheEnvironmentAndDotEnv(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, ".env"),
[]byte("# keys\nexport OPENAI_API_KEY=\"sk-123\"\nGEMINI_API_KEY=\nLAUNCHDARKLY_SDK_KEY=sdk-1\n"), 0o600))

keys := ProviderKeys(dir, []string{"ANTHROPIC_API_KEY=sk-ant", "MISTRAL_API_KEY=", "PATH=/bin"})

assert.Equal(t, []string{"ANTHROPIC_API_KEY", "OPENAI_API_KEY"}, keys, "empty values do not count")
}

func TestBuildFacts_HasProjectEnvironmentAndKeyNames(t *testing.T) {
facts := BuildFacts(Task{
SDKID: "python-server-sdk", ProjectKey: "experimentation", EnvironmentKey: "production",
ProviderKeys: []string{"ANTHROPIC_API_KEY"},
})

assert.Contains(t, facts, "- LaunchDarkly project: experimentation")
assert.Contains(t, facts, "- LaunchDarkly environment: production")
assert.Contains(t, facts, "- Model provider keys that are set: ANTHROPIC_API_KEY (names only)")
assert.Contains(t, facts, "Do not look for keys")
assert.Contains(t, BuildFacts(Task{}), "- Model provider keys that are set: none")
}

func TestProviderKeys_BlankQuotedValueIsNotSet(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, ".env"), []byte("OPENAI_API_KEY=\" \"\nANTHROPIC_API_KEY=' x '\n"), 0o600))
assert.Equal(t, []string{"ANTHROPIC_API_KEY"}, ProviderKeys(dir, nil))
}
6 changes: 6 additions & 0 deletions internal/setup/agent/relay_proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"context"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"net/http/httputil"
Expand Down Expand Up @@ -72,6 +74,9 @@ func startRelayProxy(relayBase, accessToken string) (*relayProxy, error) {
// Send each part of a response as it arrives. The flag stream is a
// long-lived event stream, and a buffer would hold its events back.
FlushInterval: -1,
// The proxy logs to stderr by default, which writes into the wizard's
// screen. A failed request reaches the SDK as an error anyway.
ErrorLog: log.New(io.Discard, "", 0),
}
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != prefix && !strings.HasPrefix(r.URL.Path, prefix+"/") {
Expand All @@ -85,6 +90,7 @@ func startRelayProxy(relayBase, accessToken string) (*relayProxy, error) {
server: &http.Server{
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
ErrorLog: log.New(io.Discard, "", 0),
},
listener: listener,
baseURL: "http://" + listener.Addr().String() + prefix,
Expand Down
Loading
Loading