opencode-cursor-agent/internal/workspace/workspace.go

133 lines
4.1 KiB
Go
Raw Normal View History

2026-04-18 14:08:01 +00:00
// Package workspace sets up an isolated temp directory for each Cursor CLI /
// ACP child. It pre-populates a minimal .cursor config so the agent does not
// load the real user's global rules from ~/.cursor, and returns a set of
// environment overrides (HOME, CURSOR_CONFIG_DIR, XDG_CONFIG_HOME, APPDATA…)
// so the child cannot escape back to the real profile.
//
// Ported from cursor-api-proxy/src/lib/workspace.ts.
package workspace
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
)
// ChatOnly prepares an isolated temp workspace and returns:
// - dir: the absolute path of the new temp directory (caller is responsible
// for removing it when the child process exits).
// - env: a map of environment variables to override on the child.
//
// Auth is the tricky part. The Cursor CLI resolves login tokens from the
// real user profile (macOS keychain on darwin, ~/.cursor/agent-cli-state.json
// elsewhere); if we override HOME to the temp dir, `agent --print` dies with
// "Authentication required. Please run 'agent login' first…". So we keep
// HOME untouched unless either:
// - CURSOR_API_KEY is set (the CLI uses the env var and doesn't need HOME), or
// - authConfigDir is non-empty (account-pool mode, not used here yet).
//
// We *do* always override CURSOR_CONFIG_DIR → tempDir/.cursor. That's the
// setting Cursor uses to locate rules/, cli-config.json, and per-project
// state, so this single override is enough to stop the agent from loading
// the user's real ~/.cursor/rules/* into the prompt.
func ChatOnly(authConfigDir string) (dir string, env map[string]string, err error) {
tempDir, err := os.MkdirTemp("", "cursor-adapter-ws-*")
if err != nil {
return "", nil, fmt.Errorf("mkdtemp: %w", err)
}
cursorDir := filepath.Join(tempDir, ".cursor")
if err := os.MkdirAll(filepath.Join(cursorDir, "rules"), 0o755); err != nil {
_ = os.RemoveAll(tempDir)
return "", nil, fmt.Errorf("mkdir .cursor/rules: %w", err)
}
minimalConfig := map[string]any{
"version": 1,
"editor": map[string]any{"vimMode": false},
"permissions": map[string]any{
"allow": []any{},
"deny": []any{},
},
}
cfgBytes, _ := json.Marshal(minimalConfig)
if err := os.WriteFile(filepath.Join(cursorDir, "cli-config.json"), cfgBytes, 0o644); err != nil {
_ = os.RemoveAll(tempDir)
return "", nil, fmt.Errorf("write cli-config.json: %w", err)
}
env = map[string]string{}
if authConfigDir != "" {
env["CURSOR_CONFIG_DIR"] = authConfigDir
return tempDir, env, nil
}
env["CURSOR_CONFIG_DIR"] = cursorDir
// Only fully isolate HOME if the child will auth via CURSOR_API_KEY.
// With keychain/home-based auth, replacing HOME makes agent exit 1.
if os.Getenv("CURSOR_API_KEY") != "" {
env["HOME"] = tempDir
env["USERPROFILE"] = tempDir
if runtime.GOOS == "windows" {
appDataRoaming := filepath.Join(tempDir, "AppData", "Roaming")
appDataLocal := filepath.Join(tempDir, "AppData", "Local")
_ = os.MkdirAll(appDataRoaming, 0o755)
_ = os.MkdirAll(appDataLocal, 0o755)
env["APPDATA"] = appDataRoaming
env["LOCALAPPDATA"] = appDataLocal
} else {
xdg := filepath.Join(tempDir, ".config")
_ = os.MkdirAll(xdg, 0o755)
env["XDG_CONFIG_HOME"] = xdg
}
}
return tempDir, env, nil
}
// MergeEnv takes the current process env (as "KEY=VALUE" strings) and
// overlays overrides on top, returning a new slice suitable for exec.Cmd.Env.
// Keys from overrides replace any existing entries with the same key.
func MergeEnv(base []string, overrides map[string]string) []string {
if len(overrides) == 0 {
return base
}
out := make([]string, 0, len(base)+len(overrides))
seen := make(map[string]bool, len(overrides))
for _, kv := range base {
eq := indexOf(kv, '=')
if eq < 0 {
out = append(out, kv)
continue
}
key := kv[:eq]
if v, ok := overrides[key]; ok {
out = append(out, key+"="+v)
seen[key] = true
} else {
out = append(out, kv)
}
}
for k, v := range overrides {
if !seen[k] {
out = append(out, k+"="+v)
}
}
return out
}
func indexOf(s string, c byte) int {
for i := 0; i < len(s); i++ {
if s[i] == c {
return i
}
}
return -1
}