diff --git a/cmd/setup/agent.go b/cmd/setup/agent.go index 73587ecc..999bc9fe 100644 --- a/cmd/setup/agent.go +++ b/cmd/setup/agent.go @@ -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 @@ -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") @@ -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) diff --git a/cmd/setup/agent_test.go b/cmd/setup/agent_test.go index 08749ad6..b003291a 100644 --- a/cmd/setup/agent_test.go +++ b/cmd/setup/agent_test.go @@ -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() @@ -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") } diff --git a/cmd/setup/commands.go b/cmd/setup/commands.go index 56591d16..824d8b37 100644 --- a/cmd/setup/commands.go +++ b/cmd/setup/commands.go @@ -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} 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/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 09f3b04a..f9e95275 100644 --- a/internal/setup/agent/prompt.go +++ b/internal/setup/agent/prompt.go @@ -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. 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 @@ -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 { @@ -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) return b.String() } diff --git a/internal/setup/agent/provider_keys.go b/internal/setup/agent/provider_keys.go new file mode 100644 index 00000000..63a2516a --- /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.TrimSpace(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..7f4f2021 --- /dev/null +++ b/internal/setup/agent/provider_keys_test.go @@ -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)) +} 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 93d9aacc..6a55ab8f 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", @@ -114,19 +120,28 @@ 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)) + } // A cache or OpenCode folder can be missing on first use. Commands can // create its missing parent folders, but cannot write anything else there. for _, parent := range writeParents(p) { - fmt.Fprintf(&b, "(allow file-read-metadata file-write-create (literal %s))\n", sbString(parent)) + fmt.Fprintf(&b, "(allow file-write-create (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)) @@ -137,6 +152,36 @@ func seatbeltProfile(p sandboxPaths) string { return b.String() } +// parentFolders returns the closed folders between each open folder and the +// 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 + add := func(path string) { + if !seen[path] { + seen[path] = true + out = append(out, path) + } + } + open := append(append([]string{p.RunTemp}, p.ReadWrite...), p.ReadOnly...) + for _, root := range []string{p.Home, p.Temp} { + if root == "" { + continue + } + 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 +} + // writeParents returns the folders between each read-write folder and the // home folder, without the home folder and without repeats. func writeParents(p sandboxPaths) []string { @@ -215,20 +260,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) } // Resolve the home folder first, so installDir compares it with the // resolved program path. @@ -244,17 +300,34 @@ func sandboxCommand(binary string, args []string, dir string, env []string) (str paths := buildSandboxPaths(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 fa7e27cc..68f2601f 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...) { @@ -92,30 +93,77 @@ 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"))`) } func TestSandboxCommand_CreatesMissingCacheFolders(t *testing.T) { @@ -133,10 +181,10 @@ func TestSandboxCommand_CreatesMissingCacheFolders(t *testing.T) { require.NoError(t, err) sandboxed := func(binary string, args ...string) (string, error) { - name, sbArgs, cleanup, err := sandboxCommand(binary, args, project, os.Environ()) + box, err := sandboxCommand(binary, args, project, os.Environ()) require.NoError(t, err) - defer cleanup() - out, err := exec.Command(name, sbArgs...).CombinedOutput() + defer box.Cleanup() + out, err := exec.Command(box.Name, box.Args...).CombinedOutput() return string(out), err } @@ -149,6 +197,43 @@ func TestSandboxCommand_CreatesMissingCacheFolders(t *testing.T) { 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))) +} + func TestSandboxCommand_SymlinkedHomeKeepsSharedBinFolderClosed(t *testing.T) { if runtime.GOOS != "darwin" { t.Skip("the sandbox is macOS only") @@ -169,10 +254,10 @@ func TestSandboxCommand_SymlinkedHomeKeepsSharedBinFolderClosed(t *testing.T) { project := filepath.Join(home, "app") require.NoError(t, os.MkdirAll(project, 0o700)) - _, args, cleanup, err := sandboxCommand(bin, nil, project, os.Environ()) + box, err := sandboxCommand(bin, nil, project, os.Environ()) require.NoError(t, err) - defer cleanup() - profile, err := os.ReadFile(args[1]) + defer box.Cleanup() + profile, err := os.ReadFile(box.Args[1]) require.NoError(t, err) assert.NotContains(t, string(profile), "(subpath "+sbString(filepath.Join(realHome, ".local"))+")", "the shared ~/.local folder stays closed") diff --git a/internal/setup/agent/server.go b/internal/setup/agent/server.go index f635a8f2..581a972d 100644 --- a/internal/setup/agent/server.go +++ b/internal/setup/agent/server.go @@ -46,22 +46,29 @@ 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, // 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), - }) - 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 } + // 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 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 {