From 5b8bd44b59075d8c36c106ad60c67fea86ee9345 Mon Sep 17 00:00:00 2001 From: Anthony Torns II Date: Mon, 19 Oct 2026 23:43:11 -0700 Subject: [PATCH 1/5] fix(setup): make the agent sandbox work with uv and git A test run found three problems with the macOS sandbox. Resolving a path reads the metadata of every parent folder, and the sandbox denied it. So realpath, uv, and git failed with "Operation not permitted", and the agent fell back to pip and edited pyproject.toml by hand. The parents of each open folder now allow metadata reads, but not their contents. uv writes to ~/.local/share/uv when it inspects a managed Python, and the sandbox opened that folder read-only. It is now writable. The agent searched /private/tmp and found another program's files there. The sandbox now closes /private/tmp and gives the run its own temporary folder in it, which OpenCode gets as TMPDIR. The loopback proxy also wrote "http: proxy error" lines into the wizard's screen. It now discards its log. Co-Authored-By: Claude Opus 5.5 --- internal/setup/agent/relay_proxy.go | 6 ++ internal/setup/agent/sandbox.go | 108 +++++++++++++++++++++------ internal/setup/agent/sandbox_test.go | 72 +++++++++++++++--- internal/setup/agent/server.go | 14 ++-- 4 files changed, 162 insertions(+), 38 deletions(-) diff --git a/internal/setup/agent/relay_proxy.go b/internal/setup/agent/relay_proxy.go index 433ece89..e620d287 100644 --- a/internal/setup/agent/relay_proxy.go +++ b/internal/setup/agent/relay_proxy.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "io" + "log" "net" "net/http" "net/http/httputil" @@ -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+"/") { @@ -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, diff --git a/internal/setup/agent/sandbox.go b/internal/setup/agent/sandbox.go index 58a4af58..028ccd83 100644 --- a/internal/setup/agent/sandbox.go +++ b/internal/setup/agent/sandbox.go @@ -20,9 +20,14 @@ import ( const sandboxExec = "/usr/bin/sandbox-exec" -// sandboxPaths are the folders under the home folder that the sandbox opens. +// sandboxPaths are the folders that the sandbox closes and opens. type sandboxPaths struct { Home string + // Temp is the shared temporary folder, /private/tmp. Other programs keep + // files there, so the sandbox closes it. RunTemp is the run's own + // temporary folder inside it, and it stays open. + Temp string + RunTemp string // ReadWrite are folders that the agent and its commands can change. ReadWrite []string // ReadOnly are folders that commands must read but not change. @@ -35,6 +40,8 @@ type sandboxPaths struct { var readWriteUnderHome = []string{ ".local/share/opencode", ".local/state/opencode", + // uv writes here when it inspects or installs a managed Python. + ".local/share/uv", ".cache/opencode", ".cache/uv", ".cache/pip", @@ -59,7 +66,6 @@ var readWriteUnderHome = []string{ var readOnlyUnderHome = []string{ ".config/opencode", ".local/bin", - ".local/share/uv", ".local/share/mise", ".pyenv", ".rbenv", @@ -108,15 +114,24 @@ func buildSandboxPaths(home, dir, opencodeDir string, env []string) sandboxPaths } // seatbeltProfile returns the sandbox profile. Later rules take precedence, -// so the allow rules for the open folders come after the deny rule for the -// home folder. +// so the allow rules for the open folders come after the deny rules. func seatbeltProfile(p sandboxPaths) string { var b strings.Builder b.WriteString("(version 1)\n(allow default)\n") fmt.Fprintf(&b, "(deny file-read* file-write* (subpath %s))\n", sbString(p.Home)) - // Tools read the metadata of the home folder itself, for example to - // resolve a path, without reading what is in it. - fmt.Fprintf(&b, "(allow file-read-metadata (literal %s))\n", sbString(p.Home)) + if p.Temp != "" { + fmt.Fprintf(&b, "(deny file-read* file-write* (subpath %s))\n", sbString(p.Temp)) + fmt.Fprintf(&b, "(allow file-read-metadata (literal %s))\n", sbString(p.Temp)) + } + // Resolving a path, as realpath, uv, and git do, reads the metadata of + // every parent folder. So the parents of each open folder allow metadata + // reads, but not their contents. + for _, parent := range parentFolders(p) { + fmt.Fprintf(&b, "(allow file-read-metadata (literal %s))\n", sbString(parent)) + } + if p.RunTemp != "" { + fmt.Fprintf(&b, "(allow file-read* file-write* (subpath %s))\n", sbString(p.RunTemp)) + } for _, path := range p.ReadOnly { fmt.Fprintf(&b, "(allow file-read* (subpath %s))\n", sbString(path)) } @@ -126,6 +141,29 @@ func seatbeltProfile(p sandboxPaths) string { return b.String() } +// parentFolders returns the closed folders between each open folder and the +// home folder, the home folder included, without repeats. +func parentFolders(p sandboxPaths) []string { + seen := map[string]bool{} + var out []string + add := func(path string) { + if !seen[path] { + seen[path] = true + out = append(out, path) + } + } + add(p.Home) + for _, path := range append(append([]string{}, p.ReadWrite...), p.ReadOnly...) { + if !strings.HasPrefix(path, p.Home+"/") { + continue + } + for dir := filepath.Dir(path); dir != p.Home && strings.HasPrefix(dir, p.Home+"/"); dir = filepath.Dir(dir) { + add(dir) + } + } + return out +} + // sbString quotes a path for a Seatbelt profile. func sbString(s string) string { return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"` @@ -188,20 +226,31 @@ func mapPaths(paths []string, f func(string) string) []string { return out } -// sandboxCommand returns the program and arguments that run binary with args -// in the sandbox, and a function that removes the profile file. If the -// sandbox is not available, it returns binary and args unchanged. -func sandboxCommand(binary string, args []string, dir string, env []string) (string, []string, func(), error) { - noop := func() {} +// sandbox is how one run starts OpenCode. +type sandbox struct { + // Name and Args start the program. + Name string + Args []string + // TempDir, if set, is the run's own temporary folder. OpenCode must get + // it as TMPDIR, because the shared temporary folder is closed. + TempDir string + // Cleanup removes the profile and the run's temporary folder. + Cleanup func() +} + +// sandboxCommand returns how to run binary with args in the sandbox. If the +// sandbox is not available, it runs binary and args unchanged. +func sandboxCommand(binary string, args []string, dir string, env []string) (sandbox, error) { + plain := sandbox{Name: binary, Args: args, Cleanup: func() {}} if runtime.GOOS != "darwin" { - return binary, args, noop, nil + return plain, nil } if _, err := os.Stat(sandboxExec); err != nil { - return binary, args, noop, nil + return plain, nil } home, err := os.UserHomeDir() if err != nil { - return "", nil, noop, fmt.Errorf("could not find the home folder for the agent sandbox: %w", err) + return sandbox{}, fmt.Errorf("could not find the home folder for the agent sandbox: %w", err) } opencodeDir := "" if path, err := exec.LookPath(binary); err == nil { @@ -214,17 +263,34 @@ func sandboxCommand(binary string, args []string, dir string, env []string) (str paths := buildSandboxPaths(realPath(home), realPath(dir), opencodeDir, env) paths.ReadWrite = mapPaths(paths.ReadWrite, realPath) paths.ReadOnly = mapPaths(paths.ReadOnly, realPath) - profile := seatbeltProfile(paths) + runTemp, err := os.MkdirTemp(sharedTemp, "ldcli-agent-run-") + if err != nil { + return sandbox{}, fmt.Errorf("could not create the agent's temporary folder: %w", err) + } + paths.Temp, paths.RunTemp = realPath(sharedTemp), realPath(runTemp) + removeTemp := func() { _ = os.RemoveAll(runTemp) } f, err := os.CreateTemp("", "ldcli-agent-*.sb") if err != nil { - return "", nil, noop, err + removeTemp() + return sandbox{}, err } - if _, err := f.WriteString(profile); err != nil { + if _, err := f.WriteString(seatbeltProfile(paths)); err != nil { f.Close() os.Remove(f.Name()) - return "", nil, noop, err + removeTemp() + return sandbox{}, err } f.Close() - cleanup := func() { _ = os.Remove(f.Name()) } - return sandboxExec, append([]string{"-f", f.Name(), binary}, args...), cleanup, nil + return sandbox{ + Name: sandboxExec, + Args: append([]string{"-f", f.Name(), binary}, args...), + TempDir: runTemp, + Cleanup: func() { + _ = os.Remove(f.Name()) + removeTemp() + }, + }, nil } + +// sharedTemp is the shared temporary folder on macOS. /tmp points to it. +const sharedTemp = "/private/tmp" diff --git a/internal/setup/agent/sandbox_test.go b/internal/setup/agent/sandbox_test.go index aa1eb19c..af05f81a 100644 --- a/internal/setup/agent/sandbox_test.go +++ b/internal/setup/agent/sandbox_test.go @@ -32,6 +32,7 @@ func TestBuildSandboxPaths_OpensOnlyOpenCodesXDGFolders(t *testing.T) { assert.Contains(t, p.ReadWrite, "/Users/me/app") assert.Contains(t, p.ReadWrite, "/Users/me/.local/share/opencode") + assert.Contains(t, p.ReadWrite, "/Users/me/.local/share/uv", "uv writes when it inspects a managed Python") assert.Contains(t, p.ReadOnly, "/Users/me/.ldcli-local/opencode") assert.NotContains(t, p.ReadOnly, "/Users/me/.ldcli-local", "the ldcli configuration next to it stays closed") for _, path := range append(p.ReadOnly, p.ReadWrite...) { @@ -81,28 +82,75 @@ func TestSandboxCommand_ConfinesReadsToTheProject(t *testing.T) { write(filepath.Join(home, "code", "other-repo", "main.py"), "other"), } - run := func(path string) (string, error) { - name, args, cleanup, err := sandboxCommand("/bin/cat", []string{path}, project, os.Environ()) + run := func(name string, args ...string) (string, error) { + box, err := sandboxCommand(name, args, project, os.Environ()) require.NoError(t, err) - defer cleanup() - require.Equal(t, sandboxExec, name) - out, err := exec.Command(name, args...).CombinedOutput() + defer box.Cleanup() + require.Equal(t, sandboxExec, box.Name) + cmd := exec.Command(box.Name, box.Args...) + cmd.Env = append(os.Environ(), "TMPDIR="+box.TempDir) + out, err := cmd.CombinedOutput() return string(out), err } - out, err := run(inside) + out, err := run("/bin/cat", inside) require.NoError(t, err, out) assert.Equal(t, "inside", out) for _, path := range secrets { - out, err := run(path) + out, err := run("/bin/cat", path) assert.Error(t, err, "read %s", path) assert.Contains(t, out, "Operation not permitted", path) } - name, args, cleanup, err := sandboxCommand("/usr/bin/touch", []string{filepath.Join(project, "new.txt")}, project, os.Environ()) - require.NoError(t, err) - defer cleanup() - out2, err := exec.Command(name, args...).CombinedOutput() - require.NoError(t, err, string(out2)) + out, err = run("/usr/bin/touch", filepath.Join(project, "new.txt")) + require.NoError(t, err, out) assert.FileExists(t, filepath.Join(project, "new.txt"), "the project stays writable") + + // Resolving a path reads the metadata of each parent folder, which uv and + // git need. + out, err = run("/bin/realpath", inside) + require.NoError(t, err, out) + assert.Contains(t, out, "main.py") + + // The shared temporary folder is closed, and the run's own one is open. + other, err := os.MkdirTemp(sharedTemp, "ldcli-sandbox-test-") + require.NoError(t, err) + defer os.RemoveAll(other) + otherFile := write(filepath.Join(other, "notes.txt"), "another program's file") + out, err = run("/bin/cat", otherFile) + assert.Error(t, err) + assert.Contains(t, out, "Operation not permitted") + out, err = run("/bin/sh", "-c", `echo ok > "$TMPDIR/scratch" && cat "$TMPDIR/scratch"`) + require.NoError(t, err, out) + assert.Equal(t, "ok\n", out) +} + +func TestSandboxCommand_RemovesTheRunTempFolder(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("the sandbox is macOS only") + } + if _, err := os.Stat(sandboxExec); err != nil { + t.Skip("sandbox-exec is not available") + } + box, err := sandboxCommand("/bin/true", nil, t.TempDir(), os.Environ()) + require.NoError(t, err) + require.DirExists(t, box.TempDir) + + box.Cleanup() + + assert.NoDirExists(t, box.TempDir) +} + +func TestSeatbeltProfile_OpensParentMetadataOnly(t *testing.T) { + profile := seatbeltProfile(sandboxPaths{ + Home: "/Users/me", + Temp: "/private/tmp", + RunTemp: "/private/tmp/ldcli-agent-run-1", + ReadWrite: []string{"/Users/me/code/app"}, + }) + + assert.Contains(t, profile, `(allow file-read-metadata (literal "/Users/me/code"))`) + assert.NotContains(t, profile, `(allow file-read* (subpath "/Users/me/code"))`, "the parent's contents stay closed") + assert.Contains(t, profile, `(deny file-read* file-write* (subpath "/private/tmp"))`) + assert.Contains(t, profile, `(allow file-read* file-write* (subpath "/private/tmp/ldcli-agent-run-1"))`) } diff --git a/internal/setup/agent/server.go b/internal/setup/agent/server.go index 66efcba0..fe12e483 100644 --- a/internal/setup/agent/server.go +++ b/internal/setup/agent/server.go @@ -46,19 +46,23 @@ func startServer(ctx context.Context, binary, dir string, config []byte) (*serve return nil, err } - env := childEnv(os.Environ(), map[string]string{ + set := map[string]string{ "OPENCODE_SERVER_PASSWORD": password, "OPENCODE_CONFIG_CONTENT": string(config), - }) - name, args, cleanup, err := sandboxCommand(binary, []string{"serve", "--hostname", "127.0.0.1", "--port", strconv.Itoa(port)}, dir, env) + } + box, err := sandboxCommand(binary, []string{"serve", "--hostname", "127.0.0.1", "--port", strconv.Itoa(port)}, dir, os.Environ()) if err != nil { return nil, err } + if box.TempDir != "" { + set["TMPDIR"] = box.TempDir + } + cleanup := box.Cleanup procCtx, cancel := context.WithCancel(context.Background()) - cmd := exec.CommandContext(procCtx, name, args...) + cmd := exec.CommandContext(procCtx, box.Name, box.Args...) cmd.Dir = dir - cmd.Env = env + cmd.Env = childEnv(os.Environ(), set) // 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 { From 9378e4a7ce947611c3c121c908b424f6f6e609eb Mon Sep 17 00:00:00 2001 From: Anthony Torns II Date: Mon, 19 Oct 2026 23:43:11 -0700 Subject: [PATCH 2/5] feat(setup): give the agent the project, environment, and provider keys In test runs, the agent spent about 25 tool calls finding the project and environment that setup used, and it printed the environment or asked the user to paste an API key to learn whether one was set. The facts message now also gives the LaunchDarkly project and environment, and the names of the model provider keys that are set in the environment or in the project's .env. It never gives a value. It tells the agent not to look for keys, print the environment, or ask for a key, and to use $TMPDIR instead of /tmp. `ldcli setup agent` takes --project and --environment for the same facts. Co-Authored-By: Claude Opus 5.5 --- cmd/setup/agent.go | 17 +++++- cmd/setup/agent_test.go | 6 +- cmd/setup/commands.go | 4 +- internal/setup/agent/agent.go | 7 +++ internal/setup/agent/prompt.go | 11 ++++ internal/setup/agent/provider_keys.go | 70 ++++++++++++++++++++++ internal/setup/agent/provider_keys_test.go | 33 ++++++++++ 7 files changed, 142 insertions(+), 6 deletions(-) create mode 100644 internal/setup/agent/provider_keys.go create mode 100644 internal/setup/agent/provider_keys_test.go diff --git a/cmd/setup/agent.go b/cmd/setup/agent.go index 80b7cee1..1236a4bc 100644 --- a/cmd/setup/agent.go +++ b/cmd/setup/agent.go @@ -15,6 +15,7 @@ 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" @@ -48,8 +49,15 @@ func parseAgentMode(s string) (agentMode, error) { } // 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 @@ -122,6 +130,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") @@ -150,7 +160,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) diff --git a/cmd/setup/agent_test.go b/cmd/setup/agent_test.go index 900ac4af..68a5b855 100644 --- a/cmd/setup/agent_test.go +++ b/cmd/setup/agent_test.go @@ -130,7 +130,9 @@ func TestWizard_AgentOff_UsesInit(t *testing.T) { func TestWizard_AgentAuto_StartsAgentWithTheTask(t *testing.T) { runner := &fakeRunner{run: newFakeRun()} - m, cmd := update(t, agentWizard(agentAuto, runner), flagCreatedMsg{key: "my-new-flag"}) + w := agentWizard(agentAuto, 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() @@ -140,6 +142,8 @@ func TestWizard_AgentAuto_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") } diff --git a/cmd/setup/commands.go b/cmd/setup/commands.go index 54e29826..31cd2d6f 100644 --- a/cmd/setup/commands.go +++ b/cmd/setup/commands.go @@ -206,13 +206,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} diff --git a/internal/setup/agent/agent.go b/internal/setup/agent/agent.go index 6fcaf103..af083839 100644 --- a/internal/setup/agent/agent.go +++ b/internal/setup/agent/agent.go @@ -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. diff --git a/internal/setup/agent/prompt.go b/internal/setup/agent/prompt.go index ba39f947..29bcf8e2 100644 --- a/internal/setup/agent/prompt.go +++ b/internal/setup/agent/prompt.go @@ -64,11 +64,22 @@ 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)) + b.WriteString("\nldcli gives the user the SDK credential. Do not look for keys, print the environment, or ask the user for a key. Use your temporary folder in $TMPDIR, not /tmp.\n") return b.String() } +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 diff --git a/internal/setup/agent/provider_keys.go b/internal/setup/agent/provider_keys.go new file mode 100644 index 00000000..0c087c8a --- /dev/null +++ b/internal/setup/agent/provider_keys.go @@ -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.Trim(strings.TrimSpace(value), `"'`) + if ok && value != "" { + names[strings.TrimSpace(name)] = true + } + } + return names +} diff --git a/internal/setup/agent/provider_keys_test.go b/internal/setup/agent/provider_keys_test.go new file mode 100644 index 00000000..d1d62314 --- /dev/null +++ b/internal/setup/agent/provider_keys_test.go @@ -0,0 +1,33 @@ +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") +} From 78ed988f22743b11e29e129fb4d1640bcf0e352a Mon Sep 17 00:00:00 2001 From: Anthony Torns II Date: Tue, 20 Oct 2026 11:44:33 -0700 Subject: [PATCH 3/5] fix(setup): give the fallback prompt the new facts and skip blank dotenv keys BuildPrompt now gives the project, the environment, the provider key names, and the same rules as BuildFacts. The agent gets these facts when the AI Config cannot load. ProviderKeys now trims the value after it removes the quotes, so a quoted blank value does not count as a set key. Co-Authored-By: Claude Opus 5.5 --- internal/setup/agent/agent_test.go | 10 ++++++++++ internal/setup/agent/prompt.go | 13 ++++++++++++- internal/setup/agent/provider_keys.go | 2 +- internal/setup/agent/provider_keys_test.go | 6 ++++++ 4 files changed, 29 insertions(+), 2 deletions(-) diff --git a/internal/setup/agent/agent_test.go b/internal/setup/agent/agent_test.go index deac69f3..a2341075 100644 --- a/internal/setup/agent/agent_test.go +++ b/internal/setup/agent/agent_test.go @@ -440,3 +440,13 @@ 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") + } +} diff --git a/internal/setup/agent/prompt.go b/internal/setup/agent/prompt.go index 17b33865..f9e95275 100644 --- a/internal/setup/agent/prompt.go +++ b/internal/setup/agent/prompt.go @@ -93,10 +93,17 @@ func BuildFacts(t Task) string { 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)) - b.WriteString("\nldcli gives the user the SDK credential. Do not look for keys, print the environment, or ask the user for a key. Use your temporary folder in $TMPDIR, not /tmp.\n") + 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. Use your temporary folder in $TMPDIR, not /tmp.\n" + func providerKeysFact(names []string) string { if len(names) == 0 { return "none" @@ -114,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 { @@ -129,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) return b.String() } diff --git a/internal/setup/agent/provider_keys.go b/internal/setup/agent/provider_keys.go index 0c087c8a..63a2516a 100644 --- a/internal/setup/agent/provider_keys.go +++ b/internal/setup/agent/provider_keys.go @@ -61,7 +61,7 @@ func dotEnvNames(path string) map[string]bool { continue } name, value, ok := strings.Cut(line, "=") - value = strings.Trim(strings.TrimSpace(value), `"'`) + value = strings.TrimSpace(strings.Trim(strings.TrimSpace(value), `"'`)) if ok && value != "" { names[strings.TrimSpace(name)] = true } diff --git a/internal/setup/agent/provider_keys_test.go b/internal/setup/agent/provider_keys_test.go index d1d62314..7f4f2021 100644 --- a/internal/setup/agent/provider_keys_test.go +++ b/internal/setup/agent/provider_keys_test.go @@ -31,3 +31,9 @@ func TestBuildFacts_HasProjectEnvironmentAndKeyNames(t *testing.T) { 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)) +} From d1cf8e9f977b67960e727b4e2f83350f16772b29 Mon Sep 17 00:00:00 2001 From: Anthony Torns II Date: Tue, 20 Oct 2026 11:47:29 -0700 Subject: [PATCH 4/5] fix(setup): always give OpenCode a TMPDIR If the sandbox gives no run folder and TMPDIR is not set, ldcli now sets TMPDIR to the system temporary folder. The prompt tells the agent to use $TMPDIR on every platform. Co-Authored-By: Claude Opus 5.5 --- internal/setup/agent/server.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/setup/agent/server.go b/internal/setup/agent/server.go index a3a63ca2..581a972d 100644 --- a/internal/setup/agent/server.go +++ b/internal/setup/agent/server.go @@ -57,8 +57,11 @@ func startServer(ctx context.Context, binary, dir string, config []byte) (*serve if err != nil { return nil, err } + // The prompt tells the agent to use $TMPDIR, so OpenCode always gets it. if box.TempDir != "" { set["TMPDIR"] = box.TempDir + } else if os.Getenv("TMPDIR") == "" { + set["TMPDIR"] = os.TempDir() } cleanup := box.Cleanup From 3ef4e62823bda7510b8d0cdb7bcce1d29c5d4073 Mon Sep 17 00:00:00 2001 From: Anthony Torns II Date: Tue, 20 Oct 2026 12:14:45 -0700 Subject: [PATCH 5/5] fix(setup): allow metadata reads on parents under the closed temporary folder parentFolders now also walks the folders between an open folder and /private/tmp. A project in a nested folder under /tmp can then use realpath, uv, and git in the sandbox. Co-Authored-By: Claude Opus 5.5 --- internal/setup/agent/sandbox.go | 19 +++++++++----- internal/setup/agent/sandbox_test.go | 37 ++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/internal/setup/agent/sandbox.go b/internal/setup/agent/sandbox.go index 56b61bb3..2090e0ba 100644 --- a/internal/setup/agent/sandbox.go +++ b/internal/setup/agent/sandbox.go @@ -153,7 +153,8 @@ func seatbeltProfile(p sandboxPaths) string { } // parentFolders returns the closed folders between each open folder and the -// home folder, the home folder included, without repeats. +// closed folder that holds it, the home folder or the shared temporary folder. +// It includes those two folders and has no repeats. func parentFolders(p sandboxPaths) []string { seen := map[string]bool{} var out []string @@ -163,13 +164,19 @@ func parentFolders(p sandboxPaths) []string { out = append(out, path) } } - add(p.Home) - for _, path := range append(append([]string{}, p.ReadWrite...), p.ReadOnly...) { - if !strings.HasPrefix(path, p.Home+"/") { + open := append(append([]string{p.RunTemp}, p.ReadWrite...), p.ReadOnly...) + for _, root := range []string{p.Home, p.Temp} { + if root == "" { continue } - for dir := filepath.Dir(path); dir != p.Home && strings.HasPrefix(dir, p.Home+"/"); dir = filepath.Dir(dir) { - add(dir) + add(root) + for _, path := range open { + if path == "" || !strings.HasPrefix(path, root+"/") { + continue + } + for dir := filepath.Dir(path); dir != root && strings.HasPrefix(dir, root+"/"); dir = filepath.Dir(dir) { + add(dir) + } } } return out diff --git a/internal/setup/agent/sandbox_test.go b/internal/setup/agent/sandbox_test.go index 22907b09..9c256b48 100644 --- a/internal/setup/agent/sandbox_test.go +++ b/internal/setup/agent/sandbox_test.go @@ -196,3 +196,40 @@ func TestSandboxCommand_CreatesMissingCacheFolders(t *testing.T) { assert.Error(t, err, "a parent folder opens for the create only") assert.Contains(t, out, "Operation not permitted") } + +func TestParentFolders_CoversFoldersUnderTheTemporaryFolder(t *testing.T) { + p := sandboxPaths{ + Home: "/Users/me", + Temp: "/private/tmp", + RunTemp: "/private/tmp/ldcli-agent-run-1", + ReadWrite: []string{"/private/tmp/work/demo/app", "/Users/me/.cache/uv"}, + } + + got := parentFolders(p) + + assert.Subset(t, got, []string{"/Users/me", "/Users/me/.cache", "/private/tmp", "/private/tmp/work", "/private/tmp/work/demo"}) + assert.NotContains(t, got, "/private/tmp/work/demo/app", "an open folder is not a parent") +} + +func TestSandboxCommand_ResolvesAProjectUnderTheTemporaryFolder(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("the sandbox is macOS only") + } + if _, err := os.Stat(sandboxExec); err != nil { + t.Skip("sandbox-exec is not available") + } + t.Setenv("HOME", t.TempDir()) + base, err := os.MkdirTemp(sharedTemp, "ldcli-sandbox-test-") + require.NoError(t, err) + t.Cleanup(func() { _ = os.RemoveAll(base) }) + project := filepath.Join(base, "nested", "app") + require.NoError(t, os.MkdirAll(project, 0o700)) + + box, err := sandboxCommand("/bin/realpath", []string{project}, project, os.Environ()) + require.NoError(t, err) + defer box.Cleanup() + out, err := exec.Command(box.Name, box.Args...).CombinedOutput() + + require.NoError(t, err, string(out)) + assert.Equal(t, project, strings.TrimSpace(string(out))) +}