fix(cursor-proxy): macOS local cursor CLI compatibility (#3426)
Some checks failed
CLI Release / Build and release CLI (push) Has been cancelled
Lint / ShellCheck (push) Has been cancelled
Lint / Biome Lint (push) Has been cancelled
Lint / macOS Compatibility (push) Has been cancelled

## Problem
`spawn cursor local` fails on macOS with four cascading errors:

```bash
chmod: /usr/local/bin/caddy: No such file or directory
bash: line 4: caddy: command not found
bash: /etc/hosts: Permission denied
bash: line 24: setsid: command not found
```

These occur in packages/cli/src/shared/cursor-proxy.ts (setupCursorProxy and startCursorProxy). Even though local/agents.ts wires runLocal as the runServer callback, the proxy scripts it constructs were written exclusively for Linux:

1. Caddy downloads the wrong binary — `setupCursorProxy` calls `https://caddyserver.com/api/download?os=linux&arch=amd64` unconditionally. On macOS this downloads a Linux ELF binary that immediately fails to execute.
2. Caddy install path is root-owned on macOS — the script writes to `/usr/local/bin/caddy`. On macOS this directory either doesn't exist (no Homebrew) or is owned by a system process, so `chmod +x` immediately fails without sudo.
3. `/etc/hosts` is read-only without root on macOS — the domain-spoofing step runs echo `"127.0.0.1 ..." >> /etc/hosts` directly, which fails with Permission denied because `/etc/hosts` is only writable by root on macOS (unlike some Linux setups where the invoking user may own it).
4. `setsid` is Linux-only — the non-systemd fallback in startCursorProxy calls `setsid` to detach backend processes. `setsid` is a util-linux command; it doesn't ship on macOS (or BSD, or Windows). `nohup … &` is the portable equivalent.
The downstream symptom of all four: Caddy never starts, the proxy is never listening, and Cursor's auth exchange fails — which surfaces as "The provided API key is invalid" even when the key is valid.

## Fix
- OS/arch detection — query `process.platform` and `process.arch` (already available in Bun) to select the correct Caddy download (os=darwin, arch=arm64 | amd64 for macOS; os=linux for Linux).
- User-writable install path — install Caddy to `~/.local/bin/caddy` (guaranteed writable, always exists after `mkdir -p`) instead of `/usr/local/bin/`. Add `~/.local/bin` to `PATH` in the execution environment.
- `sudo` for `/etc/hosts` — prefix the `sed` and `echo` host entries with `sudo`. Wrap in a try/catch with a clear fallback message if `sudo` isn't available (some sandboxed environments).
- Replace `setsid` with `nohup` — swap `setsid $NODE ...` for `nohup $NODE ... < /dev/null >> /tmp/cursor-proxy.log 2>&1 &` in the non-systemd branch. `nohup` is POSIX and present on macOS, Linux, and BSD.

## Affected code
- `packages/cli/src/shared/cursor-proxy.ts` — `installCaddy` shell fragment, `hosts-spoofing` fragment, `startCursorProxy` non-systemd branch
- No changes to the CloudRunner interface, tests, or other agents

## Testing
Tested on macOS (Apple Silicon, M3) with SPAWN_CLI_DIR pointing at a local build:
- `spawn cursor local` completes all setup steps without errors
- Caddy starts on port 443, unary backend on 18644, bidi backend on 18645
- Cursor authenticates and routes completions through OpenRouter
This commit is contained in:
Audrey Sage Lorberfeld 2026-05-21 16:23:37 -07:00 • committed by GitHub
parent 18bbe3e9a5
commit 2221f1155b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 31 additions and 12 deletions

View file

@ -1,12 +1,12 @@
{
"name": "@openrouter/spawn",
"version": "0.2.82",
"version": "1.0.45",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openrouter/spawn",
"version": "0.2.82",
"version": "1.0.45",
"dependencies": {
"@clack/prompts": "^1.0.0",
"picocolors": "^1.1.1"

View file

@ -279,11 +279,22 @@ export async function setupCursorProxy(runner: CloudRunner): Promise<void> {
logStep("Deploying Cursor→OpenRouter proxy...");
// 1. Install Caddy if not present
// Detect OS and arch at runtime so this works on macOS (darwin/arm64|amd64)
// and Linux (linux/amd64|arm64). Write to ~/.local/bin which is user-writable
// on every platform -- /usr/local/bin is SIP-protected on macOS and requires
// root elsewhere.
const installCaddy = [
"set -e",
'if command -v caddy >/dev/null 2>&1; then echo "caddy already installed"; exit 0; fi',
'echo "Installing Caddy..."',
'curl -sf "https://caddyserver.com/api/download?os=linux&arch=amd64" -o /usr/local/bin/caddy',
"chmod +x /usr/local/bin/caddy",
'OS=$(uname -s | tr "[:upper:]" "[:lower:]")',
"ARCH=$(uname -m)",
'[ "$ARCH" = "x86_64" ] && ARCH="amd64"',
'[ "$ARCH" = "aarch64" ] && ARCH="arm64"',
'mkdir -p "$HOME/.local/bin"',
'curl -fsSL "https://caddyserver.com/api/download?os=${OS}&arch=${ARCH}" -o "$HOME/.local/bin/caddy"',
'chmod +x "$HOME/.local/bin/caddy"',
'export PATH="$HOME/.local/bin:$PATH"',
"caddy version",
].join("\n");
@ -322,18 +333,23 @@ export async function setupCursorProxy(runner: CloudRunner): Promise<void> {
logInfo("Proxy scripts deployed");
// 3. Configure /etc/hosts for domain spoofing
// Requires elevated privileges on all platforms (/etc/hosts is root-owned).
// Use a temp-file swap via sudo cp to avoid sed -i portability issues
// (macOS sed requires a backup suffix with -i; Linux does not).
const hostsScript = [
// Remove any existing cursor entries
'sed -i "/cursor\\.sh/d" /etc/hosts 2>/dev/null || true',
// Add our entries
`echo "127.0.0.1 ${CURSOR_DOMAINS.join(" ")}" >> /etc/hosts`,
// Build a clean copy without existing cursor entries then append ours
'grep -v "cursor.sh" /etc/hosts > /tmp/hosts_cursor_new 2>/dev/null || cp /etc/hosts /tmp/hosts_cursor_new',
`echo "127.0.0.1 ${CURSOR_DOMAINS.join(" ")}" >> /tmp/hosts_cursor_new`,
"sudo cp /tmp/hosts_cursor_new /etc/hosts",
"rm -f /tmp/hosts_cursor_new",
].join(" && ");
await wrapSshCall(runner.runServer(hostsScript));
logInfo("Hosts spoofing configured");
// 4. Install Caddy's internal CA cert
const trustScript = "caddy trust 2>/dev/null || true";
// Include ~/.local/bin in PATH since Caddy may have been installed there above.
const trustScript = 'export PATH="$HOME/.local/bin:$PATH" && caddy trust 2>/dev/null || true';
await wrapSshCall(runner.runServer(trustScript, 30));
logInfo("Caddy CA trusted");
@ -353,7 +369,7 @@ CONF`,
/**
* Start the Cursor proxy services (Caddy + two Node.js backends).
* Uses systemd if available, falls back to setsid/nohup.
* Uses systemd if available, falls back to nohup (POSIX -- works on macOS and Linux).
*/
export async function startCursorProxy(runner: CloudRunner): Promise<void> {
logStep("Starting Cursor proxy services...");
@ -370,6 +386,8 @@ export async function startCursorProxy(runner: CloudRunner): Promise<void> {
const script = [
"source ~/.spawnrc 2>/dev/null",
// Ensure ~/.local/bin (where Caddy may be installed) is in PATH for this script.
'export PATH="$HOME/.local/bin:$PATH"',
nodeFind,
// Start unary backend
@ -395,7 +413,8 @@ export async function startCursorProxy(runner: CloudRunner): Promise<void> {
" $_sudo systemctl daemon-reload",
" $_sudo systemctl restart cursor-proxy-unary",
" else",
" setsid $NODE ~/.cursor/proxy/unary.mjs < /dev/null &",
// setsid is Linux-only; nohup is POSIX and works on macOS and Linux.
" nohup $NODE ~/.cursor/proxy/unary.mjs < /dev/null > /tmp/cursor-proxy-unary.log 2>&1 &",
" fi",
"fi",
@ -423,7 +442,7 @@ export async function startCursorProxy(runner: CloudRunner): Promise<void> {
" $_sudo systemctl daemon-reload",
" $_sudo systemctl restart cursor-proxy-bidi",
" else",
" setsid $NODE ~/.cursor/proxy/bidi.mjs < /dev/null &",
" nohup $NODE ~/.cursor/proxy/bidi.mjs < /dev/null > /tmp/cursor-proxy-bidi.log 2>&1 &",
" fi",
"fi",