// 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 }